my/ui

Command Palette

Search for a command to run...

All components

Choice Poll

data-display

Poll component with single or multiple selection, optional results, and keyboard navigation

responsive · 500px

Install

Same command in any shadcn project — React (Vite/CRA), Next.js, Remix, Astro, and more:

$npx shadcn@latest add https://your-domain/r/choice-poll.json

Usage

"use client"

import { ChoicePoll } from "@/registry/cult-ui/choice-poll"

export default function Demo() {
  return (
    <div className="w-full max-w-md p-6">
      <ChoicePoll.Root
        votes={{ react: 245, vue: 178, angular: 92, svelte: 134 }}
        hasVoted={false}
        showResults={false}
      >
        <ChoicePoll.Header>
          <ChoicePoll.Title>What is your favorite frontend framework?</ChoicePoll.Title>
          <ChoicePoll.Description>Vote for the framework you enjoy working with the most.</ChoicePoll.Description>
        </ChoicePoll.Header>
        <ChoicePoll.Options>
          <ChoicePoll.Option value="react">
            <ChoicePoll.Indicator />
            <ChoicePoll.Label>React</ChoicePoll.Label>
          </ChoicePoll.Option>
          <ChoicePoll.Option value="vue">
            <ChoicePoll.Indicator />
            <ChoicePoll.Label>Vue</ChoicePoll.Label>
          </ChoicePoll.Option>
          <ChoicePoll.Option value="angular">
            <ChoicePoll.Indicator />
            <ChoicePoll.Label>Angular</ChoicePoll.Label>
          </ChoicePoll.Option>
          <ChoicePoll.Option value="svelte">
            <ChoicePoll.Indicator />
            <ChoicePoll.Label>Svelte</ChoicePoll.Label>
          </ChoicePoll.Option>
        </ChoicePoll.Options>
      </ChoicePoll.Root>
    </div>
  )
}

Component source

"use client"

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ComponentProps,
  type KeyboardEvent,
  type MouseEvent,
} from "react"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
import { cva } from "class-variance-authority"
import { Check } from "lucide-react"

import { cn } from "@/lib/utils"

/* -----------------------------------------------------------------------------
 * Types
 * -------------------------------------------------------------------------- */

export interface ChoicePollRootProps
  extends Omit<ComponentProps<"div">, "defaultValue"> {
  /** Currently selected option(s) - controlled */
  value?: string | string[]
  /** Default selected option(s) - uncontrolled */
  defaultValue?: string | string[]
  /** Callback when selection changes */
  onValueChange?: (value: string | string[]) => void
  /** Whether multiple selections are allowed */
  multiple?: boolean
  /** Whether the poll is disabled */
  disabled?: boolean
  /** Whether poll results should be visible after voting */
  showResults?: boolean
  /** Vote counts per option (for showing results) */
  votes?: Record<string, number>
  /** Whether user has submitted their vote */
  hasVoted?: boolean
}

export interface ChoicePollOptionProps extends ComponentProps<"button"> {
  /** Unique identifier for this option */
  value: string
  /** Whether this specific option is disabled */
  disabled?: boolean
}

export type ChoicePollHeaderProps = ComponentProps<"div">

export type ChoicePollTitleProps = ComponentProps<"h3">

export type ChoicePollDescriptionProps = ComponentProps<"p">

export type ChoicePollOptionsProps = ComponentProps<"div">

export type ChoicePollLabelProps = ComponentProps<"span">

export type ChoicePollIndicatorProps = ComponentProps<"span">

export type ChoicePollProgressProps = ComponentProps<"div">

export type ChoicePollPercentageProps = ComponentProps<"span">

export interface ChoicePollFooterProps extends ComponentProps<"div"> {
  /** Total number of votes */
  totalVotes?: number
}

/* -----------------------------------------------------------------------------
 * Context
 * -------------------------------------------------------------------------- */

interface ChoicePollContextValue {
  selected: string[]
  multiple: boolean
  disabled: boolean
  showResults: boolean
  votes: Record<string, number>
  totalVotes: number
  hasVoted: boolean
  select: (optionId: string) => void
  isSelected: (optionId: string) => boolean
  getPercentage: (optionId: string) => number
}

const ChoicePollContext = createContext<ChoicePollContextValue | null>(null)

function useChoicePollContext() {
  const context = useContext(ChoicePollContext)
  if (!context) {
    throw new Error("ChoicePoll components must be used within ChoicePoll.Root")
  }
  return context
}

interface ChoicePollOptionContextValue {
  optionId: string
  disabled: boolean
  isSelected: boolean
  percentage: number
}

const ChoicePollOptionContext =
  createContext<ChoicePollOptionContextValue | null>(null)

function useChoicePollOptionContext() {
  const context = useContext(ChoicePollOptionContext)
  if (!context) {
    throw new Error(
      "ChoicePoll.Option sub-components must be used within ChoicePoll.Option"
    )
  }
  return context
}

function useAnimatedPercentage(percentage: number, shouldShowResults: boolean) {
  const [animatedPercentage, setAnimatedPercentage] = useState(
    shouldShowResults ? percentage : 0
  )

  useEffect(() => {
    if (!shouldShowResults) {
      setAnimatedPercentage(0)
      return
    }

    const frame = requestAnimationFrame(() => {
      setAnimatedPercentage(percentage)
    })

    return () => cancelAnimationFrame(frame)
  }, [percentage, shouldShowResults])

  return animatedPercentage
}

/* -----------------------------------------------------------------------------
 * Variants
 * -------------------------------------------------------------------------- */

const optionVariants = cva(
  [
    "group relative flex w-full cursor-pointer items-center gap-3 rounded-xl border p-4 text-left",
    "transition-all duration-200 ease-out",
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
    "disabled:cursor-not-allowed disabled:opacity-50",
  ],
  {
    variants: {
      state: {
        idle: [
          "border-border bg-background hover:border-primary/50 hover:bg-accent/50",
        ],
        selected: [
          "border-primary bg-primary/5 shadow-sm",
          "hover:border-primary hover:bg-primary/10",
        ],
        voted: ["cursor-default border-border bg-muted/30"],
      },
    },
    defaultVariants: {
      state: "idle",
    },
  }
)

const indicatorVariants = cva(
  [
    "flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2",
    "transition-all duration-200 ease-out",
  ],
  {
    variants: {
      state: {
        idle: "border-muted-foreground/30 bg-background",
        selected: "border-primary bg-primary text-primary-foreground",
        voted: "border-muted-foreground/30 bg-muted",
      },
      multiple: {
        true: "rounded-md",
        false: "rounded-full",
      },
    },
    defaultVariants: {
      state: "idle",
      multiple: false,
    },
  }
)

const progressVariants = cva(
  [
    "absolute inset-y-0 left-0 rounded-l-xl",
    "transition-all duration-500 ease-out",
  ],
  {
    variants: {
      state: {
        idle: "bg-transparent",
        selected: "bg-primary/15",
        voted: "bg-primary/10",
      },
    },
    defaultVariants: {
      state: "idle",
    },
  }
)

/* -----------------------------------------------------------------------------
 * Root
 * -------------------------------------------------------------------------- */

function ChoicePollRoot({
  value: controlledValue,
  defaultValue,
  onValueChange,
  multiple = false,
  disabled = false,
  showResults = false,
  votes = {},
  hasVoted = false,
  children,
  className,
  ...props
}: ChoicePollRootProps) {
  const normalizeValue = (val: string | string[] | undefined): string[] => {
    if (!val) {
      return []
    }
    return Array.isArray(val) ? val : [val]
  }

  const [selectedArray, setSelectedArray] = useControllableState<string[]>({
    prop: controlledValue ? normalizeValue(controlledValue) : undefined,
    defaultProp: normalizeValue(defaultValue),
    onChange: (arr) => {
      if (onValueChange) {
        onValueChange(multiple ? arr : (arr[0] ?? ""))
      }
    },
  })

  const selected = selectedArray ?? []

  const totalVotes = useMemo(
    () => Object.values(votes).reduce((sum, count) => sum + count, 0),
    [votes]
  )

  const select = useCallback(
    (optionId: string) => {
      if (disabled || hasVoted) {
        return
      }

      setSelectedArray((prev) => {
        const current = prev ?? []
        const isCurrentlySelected = current.includes(optionId)

        if (multiple) {
          if (isCurrentlySelected) {
            return current.filter((id) => id !== optionId)
          }
          return [...current, optionId]
        }
        if (isCurrentlySelected) {
          return []
        }
        return [optionId]
      })
    },
    [disabled, hasVoted, multiple, setSelectedArray]
  )

  const isSelected = useCallback(
    (optionId: string) => selected.includes(optionId),
    [selected]
  )

  const getPercentage = useCallback(
    (optionId: string) => {
      if (totalVotes === 0) {
        return 0
      }
      return Math.round(((votes[optionId] ?? 0) / totalVotes) * 100)
    },
    [votes, totalVotes]
  )

  const contextValue = useMemo(
    () => ({
      selected,
      multiple,
      disabled,
      showResults: showResults && hasVoted,
      votes,
      totalVotes,
      hasVoted,
      select,
      isSelected,
      getPercentage,
    }),
    [
      selected,
      multiple,
      disabled,
      showResults,
      hasVoted,
      votes,
      totalVotes,
      select,
      isSelected,
      getPercentage,
    ]
  )

  return (
    <ChoicePollContext.Provider value={contextValue}>
      <div
        className={cn("flex flex-col gap-4", className)}
        data-disabled={disabled ? true : undefined}
        data-has-voted={hasVoted ? true : undefined}
        data-multiple={multiple ? true : undefined}
        data-slot="choice-poll"
        {...props}
      >
        {children}
      </div>
    </ChoicePollContext.Provider>
  )
}

/* -----------------------------------------------------------------------------
 * Header
 * -------------------------------------------------------------------------- */

function ChoicePollHeader({
  children,
  className,
  ...props
}: ChoicePollHeaderProps) {
  return (
    <div
      className={cn("flex flex-col gap-1", className)}
      data-slot="choice-poll-header"
      {...props}
    >
      {children}
    </div>
  )
}

/* -----------------------------------------------------------------------------
 * Title
 * -------------------------------------------------------------------------- */

function ChoicePollTitle({
  children,
  className,
  ...props
}: ChoicePollTitleProps) {
  return (
    <h3
      className={cn("font-semibold text-lg tracking-tight", className)}
      data-slot="choice-poll-title"
      {...props}
    >
      {children}
    </h3>
  )
}

/* -----------------------------------------------------------------------------
 * Description
 * -------------------------------------------------------------------------- */

function ChoicePollDescription({
  children,
  className,
  ...props
}: ChoicePollDescriptionProps) {
  return (
    <p
      className={cn("text-muted-foreground text-sm", className)}
      data-slot="choice-poll-description"
      {...props}
    >
      {children}
    </p>
  )
}

/* -----------------------------------------------------------------------------
 * Options Container
 * -------------------------------------------------------------------------- */

function ChoicePollOptions({
  children,
  className,
  ...props
}: ChoicePollOptionsProps) {
  const containerRef = useRef<HTMLDivElement>(null)

  const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
    const container = containerRef.current
    if (!container) {
      return
    }

    const options = Array.from(
      container.querySelectorAll<HTMLButtonElement>(
        '[data-slot="choice-poll-option"]:not([disabled])'
      )
    )
    const currentIndex = options.indexOf(
      document.activeElement as HTMLButtonElement
    )

    let nextIndex = currentIndex

    switch (event.key) {
      case "ArrowDown":
      case "ArrowRight":
        event.preventDefault()
        nextIndex = currentIndex < options.length - 1 ? currentIndex + 1 : 0
        break
      case "ArrowUp":
      case "ArrowLeft":
        event.preventDefault()
        nextIndex = currentIndex > 0 ? currentIndex - 1 : options.length - 1
        break
      case "Home":
        event.preventDefault()
        nextIndex = 0
        break
      case "End":
        event.preventDefault()
        nextIndex = options.length - 1
        break
      default:
        break
    }

    options[nextIndex]?.focus()
  }, [])

  return (
    <div
      className={cn("flex flex-col gap-2", className)}
      data-slot="choice-poll-options"
      onKeyDown={handleKeyDown}
      ref={containerRef}
      role="listbox"
      {...props}
    >
      {children}
    </div>
  )
}

/* -----------------------------------------------------------------------------
 * Option
 * -------------------------------------------------------------------------- */

function ChoicePollOption({
  value,
  disabled: optionDisabled = false,
  children,
  className,
  onClick,
  ...props
}: ChoicePollOptionProps) {
  const {
    disabled: rootDisabled,
    hasVoted,
    showResults,
    isSelected,
    select,
    getPercentage,
  } = useChoicePollContext()

  const disabled = rootDisabled || optionDisabled
  const selected = isSelected(value)
  const percentage = getPercentage(value)
  const animatedPercentage = useAnimatedPercentage(percentage, showResults)

  const getState = (): "idle" | "selected" | "voted" => {
    if (hasVoted) {
      return "voted"
    }
    if (selected) {
      return "selected"
    }
    return "idle"
  }
  const state = getState()

  const handleClick = useCallback(
    (event: MouseEvent<HTMLButtonElement>) => {
      onClick?.(event)
      if (!(event.defaultPrevented || disabled)) {
        select(value)
      }
    },
    [onClick, disabled, select, value]
  )

  const optionContextValue = useMemo(
    () => ({
      optionId: value,
      disabled,
      isSelected: selected,
      percentage,
    }),
    [value, disabled, selected, percentage]
  )

  return (
    <ChoicePollOptionContext.Provider value={optionContextValue}>
      <button
        aria-disabled={disabled || hasVoted}
        aria-selected={selected}
        className={cn(optionVariants({ state }), className)}
        data-disabled={disabled ? true : undefined}
        data-percentage={percentage}
        data-selected={selected ? true : undefined}
        data-slot="choice-poll-option"
        data-state={state}
        data-value={value}
        disabled={disabled || hasVoted}
        onClick={handleClick}
        role="option"
        type="button"
        {...props}
      >
        {/* Progress bar background */}
        {showResults && (
          <span
            aria-hidden="true"
            className={cn(
              progressVariants({ state: selected ? "selected" : "voted" })
            )}
            style={{ width: `${animatedPercentage}%` }}
          />
        )}

        {/* Content */}
        <span className="relative z-10 flex w-full items-center gap-3">
          {children}
        </span>
      </button>
    </ChoicePollOptionContext.Provider>
  )
}

/* -----------------------------------------------------------------------------
 * Indicator (checkbox/radio visual)
 * -------------------------------------------------------------------------- */

function ChoicePollIndicator({
  children,
  className,
  ...props
}: ChoicePollIndicatorProps) {
  const { multiple, hasVoted } = useChoicePollContext()
  const { isSelected } = useChoicePollOptionContext()

  const getState = (): "idle" | "selected" | "voted" => {
    if (hasVoted) {
      return "voted"
    }
    if (isSelected) {
      return "selected"
    }
    return "idle"
  }
  const state = getState()

  return (
    <span
      aria-hidden="true"
      className={cn(indicatorVariants({ state, multiple }), className)}
      data-slot="choice-poll-indicator"
      data-state={state}
      {...props}
    >
      {isSelected && (
        <Check
          className={cn(
            "h-3 w-3 transition-transform duration-200",
            isSelected ? "scale-100" : "scale-0"
          )}
          strokeWidth={3}
        />
      )}
      {children}
    </span>
  )
}

/* -----------------------------------------------------------------------------
 * Label
 * -------------------------------------------------------------------------- */

function ChoicePollLabel({
  children,
  className,
  ...props
}: ChoicePollLabelProps) {
  return (
    <span
      className={cn("flex-1 font-medium", className)}
      data-slot="choice-poll-label"
      {...props}
    >
      {children}
    </span>
  )
}

/* -----------------------------------------------------------------------------
 * Progress (inline progress bar)
 * -------------------------------------------------------------------------- */

function ChoicePollProgress({ className, ...props }: ChoicePollProgressProps) {
  const { showResults } = useChoicePollContext()
  const { percentage, isSelected } = useChoicePollOptionContext()
  const animatedPercentage = useAnimatedPercentage(percentage, showResults)

  if (!showResults) {
    return null
  }

  return (
    <span
      aria-hidden="true"
      className={cn(
        "h-1.5 w-16 overflow-hidden rounded-full bg-muted",
        className
      )}
      data-slot="choice-poll-progress"
      {...props}
    >
      <span
        className={cn(
          "block h-full rounded-full transition-all duration-500 ease-out",
          isSelected ? "bg-primary" : "bg-muted-foreground/30"
        )}
        style={{ width: `${animatedPercentage}%` }}
      />
    </span>
  )
}

/* -----------------------------------------------------------------------------
 * Percentage
 * -------------------------------------------------------------------------- */

function ChoicePollPercentage({
  children,
  className,
  ...props
}: ChoicePollPercentageProps) {
  const { showResults } = useChoicePollContext()
  const { percentage } = useChoicePollOptionContext()
  const animatedPercentage = useAnimatedPercentage(percentage, showResults)

  if (!showResults) {
    return null
  }

  return (
    <span
      className={cn(
        "min-w-[3ch] text-right font-medium text-muted-foreground text-sm tabular-nums",
        className
      )}
      data-slot="choice-poll-percentage"
      {...props}
    >
      {children ?? `${Math.round(animatedPercentage)}%`}
    </span>
  )
}

/* -----------------------------------------------------------------------------
 * Footer
 * -------------------------------------------------------------------------- */

function ChoicePollFooter({
  children,
  className,
  totalVotes,
  ...props
}: ChoicePollFooterProps) {
  const {
    totalVotes: contextTotalVotes,
    hasVoted,
    showResults,
  } = useChoicePollContext()
  const votes = totalVotes ?? contextTotalVotes

  if (!showResults && !children) {
    return null
  }

  return (
    <div
      className={cn(
        "flex items-center justify-between text-muted-foreground text-sm",
        className
      )}
      data-slot="choice-poll-footer"
      {...props}
    >
      {children ?? (
        <>
          <span>
            {votes.toLocaleString()} {votes === 1 ? "vote" : "votes"}
          </span>
          {hasVoted && (
            <span className="flex items-center gap-1.5 text-primary">
              <Check className="h-3.5 w-3.5" />
              <span>You voted</span>
            </span>
          )}
        </>
      )}
    </div>
  )
}

/* -----------------------------------------------------------------------------
 * Hook for external access
 * -------------------------------------------------------------------------- */

export function useChoicePoll() {
  return useChoicePollContext()
}

/* -----------------------------------------------------------------------------
 * Export
 * -------------------------------------------------------------------------- */

export const ChoicePoll = {
  Root: ChoicePollRoot,
  Header: ChoicePollHeader,
  Title: ChoicePollTitle,
  Description: ChoicePollDescription,
  Options: ChoicePollOptions,
  Option: ChoicePollOption,
  Indicator: ChoicePollIndicator,
  Label: ChoicePollLabel,
  Progress: ChoicePollProgress,
  Percentage: ChoicePollPercentage,
  Footer: ChoicePollFooter,
}

export {
  ChoicePollRoot,
  ChoicePollHeader,
  ChoicePollTitle,
  ChoicePollDescription,
  ChoicePollOptions,
  ChoicePollOption,
  ChoicePollIndicator,
  ChoicePollLabel,
  ChoicePollProgress,
  ChoicePollPercentage,
  ChoicePollFooter,
}

Dependencies

@radix-ui/react-use-controllable-statelucide-react

Source: Cult UI