All components
Feature Poll
data-displayFeature poll component with single or multiple selection, optional results, and keyboard navigation
responsive · 520px
Install
Same command in any shadcn project — React (Vite/CRA), Next.js, Remix, Astro, and more:
$
npx shadcn@latest add https://your-domain/r/feature-poll.jsonUsage
"use client"
import { FeaturePoll } from "@/registry/cult-ui/feature-poll"
export default function Demo() {
return (
<div className="w-full max-w-md p-6">
<FeaturePoll.Root
votes={{ performance: 312, dx: 201, ecosystem: 145, learning: 89 }}
hasVoted={false}
showResults={false}
>
<FeaturePoll.Header>
<FeaturePoll.Title>What matters most in a framework?</FeaturePoll.Title>
<FeaturePoll.Description>Help us prioritize what we build next.</FeaturePoll.Description>
</FeaturePoll.Header>
<FeaturePoll.Options>
<FeaturePoll.Option value="performance">
<FeaturePoll.Indicator />
<FeaturePoll.Label>Performance</FeaturePoll.Label>
</FeaturePoll.Option>
<FeaturePoll.Option value="dx">
<FeaturePoll.Indicator />
<FeaturePoll.Label>Developer Experience</FeaturePoll.Label>
</FeaturePoll.Option>
<FeaturePoll.Option value="ecosystem">
<FeaturePoll.Indicator />
<FeaturePoll.Label>Ecosystem & Libraries</FeaturePoll.Label>
</FeaturePoll.Option>
<FeaturePoll.Option value="learning">
<FeaturePoll.Indicator />
<FeaturePoll.Label>Learning Curve</FeaturePoll.Label>
</FeaturePoll.Option>
</FeaturePoll.Options>
<FeaturePoll.Footer />
</FeaturePoll.Root>
</div>
)
}Component source
"use client"
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
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 FeaturePollRootProps
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 to show results 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 FeaturePollOptionProps extends ComponentProps<"button"> {
/** Unique identifier for this option */
value: string
/** Whether this specific option is disabled */
disabled?: boolean
}
export type FeaturePollHeaderProps = ComponentProps<"div">
export type FeaturePollTitleProps = ComponentProps<"h3">
export type FeaturePollDescriptionProps = ComponentProps<"p">
export type FeaturePollOptionsProps = ComponentProps<"div">
export type FeaturePollLabelProps = ComponentProps<"span">
export type FeaturePollIndicatorProps = ComponentProps<"span">
export type FeaturePollProgressProps = ComponentProps<"div">
export type FeaturePollPercentageProps = ComponentProps<"span">
export interface FeaturePollFooterProps extends ComponentProps<"div"> {
/** Total number of votes */
totalVotes?: number
}
/* -----------------------------------------------------------------------------
* Context
* -------------------------------------------------------------------------- */
interface FeaturePollContextValue {
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 FeaturePollContext = createContext<FeaturePollContextValue | null>(null)
function useFeaturePollContext() {
const context = useContext(FeaturePollContext)
if (!context) {
throw new Error(
"FeaturePoll components must be used within FeaturePoll.Root"
)
}
return context
}
interface FeaturePollOptionContextValue {
optionId: string
disabled: boolean
isSelected: boolean
percentage: number
}
const FeaturePollOptionContext =
createContext<FeaturePollOptionContextValue | null>(null)
function useFeaturePollOptionContext() {
const context = useContext(FeaturePollOptionContext)
if (!context) {
throw new Error(
"FeaturePoll.Option sub-components must be used within FeaturePoll.Option"
)
}
return context
}
/* -----------------------------------------------------------------------------
* 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/10",
voted: "bg-muted/50",
},
},
defaultVariants: {
state: "idle",
},
}
)
/* -----------------------------------------------------------------------------
* Root
* -------------------------------------------------------------------------- */
function FeaturePollRoot({
value: controlledValue,
defaultValue,
onValueChange,
multiple = false,
disabled = false,
showResults = false,
votes = {},
hasVoted = false,
children,
className,
...props
}: FeaturePollRootProps) {
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 (
<FeaturePollContext.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="feature-poll"
{...props}
>
{children}
</div>
</FeaturePollContext.Provider>
)
}
/* -----------------------------------------------------------------------------
* Header
* -------------------------------------------------------------------------- */
function FeaturePollHeader({
children,
className,
...props
}: FeaturePollHeaderProps) {
return (
<div
className={cn("flex flex-col gap-1", className)}
data-slot="feature-poll-header"
{...props}
>
{children}
</div>
)
}
/* -----------------------------------------------------------------------------
* Title
* -------------------------------------------------------------------------- */
function FeaturePollTitle({
children,
className,
...props
}: FeaturePollTitleProps) {
return (
<h3
className={cn("font-semibold text-lg tracking-tight", className)}
data-slot="feature-poll-title"
{...props}
>
{children}
</h3>
)
}
/* -----------------------------------------------------------------------------
* Description
* -------------------------------------------------------------------------- */
function FeaturePollDescription({
children,
className,
...props
}: FeaturePollDescriptionProps) {
return (
<p
className={cn("text-muted-foreground text-sm", className)}
data-slot="feature-poll-description"
{...props}
>
{children}
</p>
)
}
/* -----------------------------------------------------------------------------
* Options Container
* -------------------------------------------------------------------------- */
function FeaturePollOptions({
children,
className,
...props
}: FeaturePollOptionsProps) {
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="feature-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="feature-poll-options"
onKeyDown={handleKeyDown}
ref={containerRef}
role="listbox"
{...props}
>
{children}
</div>
)
}
/* -----------------------------------------------------------------------------
* Option
* -------------------------------------------------------------------------- */
function FeaturePollOption({
value,
disabled: optionDisabled = false,
children,
className,
onClick,
...props
}: FeaturePollOptionProps) {
const {
disabled: rootDisabled,
hasVoted,
showResults,
isSelected,
select,
getPercentage,
} = useFeaturePollContext()
const disabled = rootDisabled || optionDisabled
const selected = isSelected(value)
const percentage = getPercentage(value)
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 (
<FeaturePollOptionContext.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="feature-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: `${percentage}%` }}
/>
)}
{/* Content */}
<span className="relative z-10 flex w-full items-center gap-3">
{children}
</span>
</button>
</FeaturePollOptionContext.Provider>
)
}
/* -----------------------------------------------------------------------------
* Indicator (checkbox/radio visual)
* -------------------------------------------------------------------------- */
function FeaturePollIndicator({
children,
className,
...props
}: FeaturePollIndicatorProps) {
const { multiple, hasVoted } = useFeaturePollContext()
const { isSelected } = useFeaturePollOptionContext()
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="feature-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 FeaturePollLabel({
children,
className,
...props
}: FeaturePollLabelProps) {
return (
<span
className={cn("flex-1 font-medium", className)}
data-slot="feature-poll-label"
{...props}
>
{children}
</span>
)
}
/* -----------------------------------------------------------------------------
* Progress (inline progress bar)
* -------------------------------------------------------------------------- */
function FeaturePollProgress({
className,
...props
}: FeaturePollProgressProps) {
const { showResults } = useFeaturePollContext()
const { percentage, isSelected } = useFeaturePollOptionContext()
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="feature-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: `${percentage}%` }}
/>
</span>
)
}
/* -----------------------------------------------------------------------------
* Percentage
* -------------------------------------------------------------------------- */
function FeaturePollPercentage({
children,
className,
...props
}: FeaturePollPercentageProps) {
const { showResults } = useFeaturePollContext()
const { percentage } = useFeaturePollOptionContext()
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="feature-poll-percentage"
{...props}
>
{children ?? `${percentage}%`}
</span>
)
}
/* -----------------------------------------------------------------------------
* Footer
* -------------------------------------------------------------------------- */
function FeaturePollFooter({
children,
className,
totalVotes,
...props
}: FeaturePollFooterProps) {
const { totalVotes: contextTotalVotes, hasVoted } = useFeaturePollContext()
const votes = totalVotes ?? contextTotalVotes
return (
<div
className={cn(
"flex items-center justify-between text-muted-foreground text-sm",
className
)}
data-slot="feature-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 useFeaturePoll() {
return useFeaturePollContext()
}
/* -----------------------------------------------------------------------------
* Export
* -------------------------------------------------------------------------- */
export const FeaturePoll = {
Root: FeaturePollRoot,
Header: FeaturePollHeader,
Title: FeaturePollTitle,
Description: FeaturePollDescription,
Options: FeaturePollOptions,
Option: FeaturePollOption,
Indicator: FeaturePollIndicator,
Label: FeaturePollLabel,
Progress: FeaturePollProgress,
Percentage: FeaturePollPercentage,
Footer: FeaturePollFooter,
}
export {
FeaturePollRoot,
FeaturePollHeader,
FeaturePollTitle,
FeaturePollDescription,
FeaturePollOptions,
FeaturePollOption,
FeaturePollIndicator,
FeaturePollLabel,
FeaturePollProgress,
FeaturePollPercentage,
FeaturePollFooter,
}Dependencies
@radix-ui/react-use-controllable-statelucide-react
Source: Cult UI