All components
Feature Voting
data-displayList of features with up-vote support, optional sorting by vote count, and controlled or uncontrolled state
responsive · 480px
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-voting.jsonUsage
"use client"
import { FeatureVoting } from "@/registry/cult-ui/feature-voting"
const features = [
{
id: "dark-mode",
title: "Dark Mode",
description: "Full dark mode support across the entire app.",
initialVotes: 42,
},
{
id: "api-integrations",
title: "API Integrations",
description: "Connect with third-party services seamlessly.",
initialVotes: 31,
},
{
id: "mobile-app",
title: "Mobile App",
description: "Native iOS and Android application.",
initialVotes: 58,
},
{
id: "export-pdf",
title: "PDF Export",
description: "Export any content to PDF format with one click.",
initialVotes: 19,
},
]
export default function Demo() {
return (
<div className="w-full max-w-lg p-4">
<h2 className="text-xl font-bold mb-4">Vote for Features</h2>
<FeatureVoting.Root
defaultValue={{ "dark-mode": 42, "api-integrations": 31, "mobile-app": 58, "export-pdf": 19 }}
className="flex flex-col gap-2"
>
{features.map((feature) => (
<FeatureVoting.Item
key={feature.id}
value={feature.id}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent/50 transition-colors"
>
<FeatureVoting.Trigger className="flex items-center justify-center w-10 h-10 rounded-lg border border-border bg-background hover:bg-primary hover:text-primary-foreground transition-colors data-[state=voted]:bg-primary data-[state=voted]:text-primary-foreground flex-col gap-0.5">
<span className="text-xs">▲</span>
<FeatureVoting.Count className="text-xs font-medium" />
</FeatureVoting.Trigger>
<div className="flex flex-col">
<FeatureVoting.Title className="font-medium text-sm">{feature.title}</FeatureVoting.Title>
<FeatureVoting.Description className="text-xs text-muted-foreground">{feature.description}</FeatureVoting.Description>
</div>
</FeatureVoting.Item>
))}
</FeatureVoting.Root>
</div>
)
}Component source
"use client"
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useMemo,
type ComponentProps,
type MouseEvent,
} from "react"
import { useControllableState } from "@radix-ui/react-use-controllable-state"
/* -----------------------------------------------------------------------------
* Types
* -------------------------------------------------------------------------- */
export type FeatureVotingValue = Record<string, number>
export interface FeatureVotingRootProps
extends Omit<ComponentProps<"ul">, "defaultValue"> {
/** Current vote counts (controlled) */
value?: FeatureVotingValue
/** Initial vote counts (uncontrolled) */
defaultValue?: FeatureVotingValue
/** Callback when votes change */
onValueChange?: (value: FeatureVotingValue) => void
/** Set of feature IDs the current user has voted for */
votedFeatures?: Set<string>
/** Default voted features (uncontrolled) */
defaultVotedFeatures?: Set<string>
/** Callback when user votes/unvotes */
onVotedFeaturesChange?: (votedFeatures: Set<string>) => void
/** Whether voting is disabled */
disabled?: boolean
}
export interface FeatureVotingItemProps extends ComponentProps<"li"> {
/** Unique identifier for this feature */
value: string
/** Whether this specific item is disabled */
disabled?: boolean
}
export type FeatureVotingTriggerProps = ComponentProps<"button">
export type FeatureVotingCountProps = ComponentProps<"span">
export type FeatureVotingTitleProps = ComponentProps<"span">
export type FeatureVotingDescriptionProps = ComponentProps<"span">
export interface FeatureVotingGroupProps extends ComponentProps<"div"> {
/** Sort items by vote count */
sortBy?: "votes-asc" | "votes-desc" | "none"
}
/* -----------------------------------------------------------------------------
* Context
* -------------------------------------------------------------------------- */
interface FeatureVotingContextValue {
votes: FeatureVotingValue
votedFeatures: Set<string>
disabled: boolean
vote: (featureId: string) => void
unvote: (featureId: string) => void
toggleVote: (featureId: string) => void
getVoteCount: (featureId: string) => number
hasVoted: (featureId: string) => boolean
}
const FeatureVotingContext = createContext<FeatureVotingContextValue | null>(
null
)
function useFeatureVotingContext() {
const context = useContext(FeatureVotingContext)
if (!context) {
throw new Error(
"FeatureVoting components must be used within FeatureVoting.Root"
)
}
return context
}
interface FeatureVotingItemContextValue {
featureId: string
disabled: boolean
}
const FeatureVotingItemContext =
createContext<FeatureVotingItemContextValue | null>(null)
function useFeatureVotingItemContext() {
const context = useContext(FeatureVotingItemContext)
if (!context) {
throw new Error(
"FeatureVoting.Item sub-components must be used within FeatureVoting.Item"
)
}
return context
}
/* -----------------------------------------------------------------------------
* Root
* -------------------------------------------------------------------------- */
function FeatureVotingRoot({
value: controlledValue,
defaultValue = {},
onValueChange,
votedFeatures: controlledVotedFeatures,
defaultVotedFeatures,
onVotedFeaturesChange,
disabled = false,
children,
...props
}: FeatureVotingRootProps) {
const [votes, setVotes] = useControllableState<FeatureVotingValue>({
prop: controlledValue,
defaultProp: defaultValue,
onChange: onValueChange,
})
const [votedFeaturesArray, setVotedFeaturesArray] = useControllableState({
prop: controlledVotedFeatures
? Array.from(controlledVotedFeatures)
: undefined,
defaultProp: defaultVotedFeatures ? Array.from(defaultVotedFeatures) : [],
onChange: (arr) => onVotedFeaturesChange?.(new Set(arr)),
})
const votedFeatures = useMemo(
() => new Set(votedFeaturesArray),
[votedFeaturesArray]
)
const vote = useCallback(
(featureId: string) => {
if (disabled || votedFeatures.has(featureId)) {
return
}
setVotes((prev) => ({
...prev,
[featureId]: (prev?.[featureId] ?? 0) + 1,
}))
setVotedFeaturesArray((prev) => [...(prev ?? []), featureId])
},
[disabled, votedFeatures, setVotes, setVotedFeaturesArray]
)
const unvote = useCallback(
(featureId: string) => {
if (disabled || !votedFeatures.has(featureId)) {
return
}
setVotes((prev) => ({
...prev,
[featureId]: Math.max((prev?.[featureId] ?? 0) - 1, 0),
}))
setVotedFeaturesArray((prev) =>
(prev ?? []).filter((id) => id !== featureId)
)
},
[disabled, votedFeatures, setVotes, setVotedFeaturesArray]
)
const toggleVote = useCallback(
(featureId: string) => {
if (votedFeatures.has(featureId)) {
unvote(featureId)
} else {
vote(featureId)
}
},
[votedFeatures, vote, unvote]
)
const getVoteCount = useCallback(
(featureId: string) => votes?.[featureId] ?? 0,
[votes]
)
const hasVoted = useCallback(
(featureId: string) => votedFeatures.has(featureId),
[votedFeatures]
)
const contextValue = useMemo(
() => ({
votes: votes ?? {},
votedFeatures,
disabled,
vote,
unvote,
toggleVote,
getVoteCount,
hasVoted,
}),
[
votes,
votedFeatures,
disabled,
vote,
unvote,
toggleVote,
getVoteCount,
hasVoted,
]
)
return (
<FeatureVotingContext.Provider value={contextValue}>
<ul
aria-label="Feature voting list"
data-disabled={disabled ? true : undefined}
{...props}
>
{children}
</ul>
</FeatureVotingContext.Provider>
)
}
/* -----------------------------------------------------------------------------
* Group (optional sorting wrapper)
* -------------------------------------------------------------------------- */
function FeatureVotingGroup({
sortBy = "none",
children,
...props
}: FeatureVotingGroupProps) {
const { votes } = useFeatureVotingContext()
const sortedChildren = useMemo(() => {
if (sortBy === "none") {
return children
}
const childArray = Children.toArray(children)
return childArray.sort((a, b) => {
if (!(isValidElement(a) && isValidElement(b))) {
return 0
}
const aValue = (a.props as FeatureVotingItemProps).value
const bValue = (b.props as FeatureVotingItemProps).value
const aVotes = votes[aValue] ?? 0
const bVotes = votes[bValue] ?? 0
return sortBy === "votes-desc" ? bVotes - aVotes : aVotes - bVotes
})
}, [children, sortBy, votes])
return <div {...props}>{sortedChildren}</div>
}
/* -----------------------------------------------------------------------------
* Item
* -------------------------------------------------------------------------- */
function FeatureVotingItem({
value,
disabled: itemDisabled = false,
children,
...props
}: FeatureVotingItemProps) {
const {
disabled: rootDisabled,
hasVoted,
getVoteCount,
} = useFeatureVotingContext()
const disabled = rootDisabled || itemDisabled
const voted = hasVoted(value)
const voteCount = getVoteCount(value)
const itemContextValue = useMemo(
() => ({ featureId: value, disabled }),
[value, disabled]
)
return (
<FeatureVotingItemContext.Provider value={itemContextValue}>
<li
data-disabled={disabled ? true : undefined}
data-feature={value}
data-slot="feature-voting-item"
data-vote-count={voteCount}
data-voted={voted ? true : undefined}
{...props}
>
{children}
</li>
</FeatureVotingItemContext.Provider>
)
}
/* -----------------------------------------------------------------------------
* Trigger
* -------------------------------------------------------------------------- */
function FeatureVotingTrigger({
children,
onClick,
...props
}: FeatureVotingTriggerProps) {
const {
toggleVote,
hasVoted,
disabled: rootDisabled,
} = useFeatureVotingContext()
const { featureId, disabled: itemDisabled } = useFeatureVotingItemContext()
const disabled = rootDisabled || itemDisabled
const voted = hasVoted(featureId)
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (!(event.defaultPrevented || disabled)) {
toggleVote(featureId)
}
},
[onClick, disabled, toggleVote, featureId]
)
return (
<button
aria-label={voted ? "Remove vote for feature" : "Vote for feature"}
aria-pressed={voted}
data-slot="feature-voting-trigger"
data-state={voted ? "voted" : "idle"}
disabled={disabled}
onClick={handleClick}
type="button"
{...props}
>
{children}
</button>
)
}
/* -----------------------------------------------------------------------------
* Count
* -------------------------------------------------------------------------- */
function FeatureVotingCount({ children, ...props }: FeatureVotingCountProps) {
const { getVoteCount } = useFeatureVotingContext()
const { featureId } = useFeatureVotingItemContext()
const count = getVoteCount(featureId)
return (
<span data-slot="feature-voting-count" {...props}>
{children ?? count}
</span>
)
}
/* -----------------------------------------------------------------------------
* Title
* -------------------------------------------------------------------------- */
function FeatureVotingTitle({ children, ...props }: FeatureVotingTitleProps) {
return (
<span data-slot="feature-voting-title" {...props}>
{children}
</span>
)
}
/* -----------------------------------------------------------------------------
* Description
* -------------------------------------------------------------------------- */
function FeatureVotingDescription({
children,
...props
}: FeatureVotingDescriptionProps) {
return (
<span data-slot="feature-voting-description" {...props}>
{children}
</span>
)
}
/* -----------------------------------------------------------------------------
* Hook for external access
* -------------------------------------------------------------------------- */
export function useFeatureVoting() {
return useFeatureVotingContext()
}
/* -----------------------------------------------------------------------------
* Export
* -------------------------------------------------------------------------- */
export const FeatureVoting = {
Root: FeatureVotingRoot,
Group: FeatureVotingGroup,
Item: FeatureVotingItem,
Trigger: FeatureVotingTrigger,
Count: FeatureVotingCount,
Title: FeatureVotingTitle,
Description: FeatureVotingDescription,
}
export {
FeatureVotingRoot,
FeatureVotingGroup,
FeatureVotingItem,
FeatureVotingTrigger,
FeatureVotingCount,
FeatureVotingTitle,
FeatureVotingDescription,
}Dependencies
@radix-ui/react-use-controllable-state
Source: Cult UI