All components
Choice Poll
data-displayPoll 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.jsonUsage
"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