All components
Vote Tally
data-displayList of items 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/vote-tally.jsonUsage
"use client"
import { VoteTally } from "@/registry/cult-ui/vote-tally"
const items = [
{
id: "typescript",
title: "TypeScript Support",
description: "Full TypeScript type safety and autocompletion.",
initialVotes: 87,
},
{
id: "testing",
title: "Automated Testing",
description: "Built-in testing utilities and CI/CD integration.",
initialVotes: 54,
},
{
id: "docs",
title: "Better Documentation",
description: "Comprehensive guides and API reference docs.",
initialVotes: 72,
},
{
id: "plugins",
title: "Plugin System",
description: "Extensible architecture with community plugins.",
initialVotes: 43,
},
]
export default function Demo() {
return (
<div className="w-full max-w-lg p-4">
<h2 className="text-xl font-bold mb-4">Community Requests</h2>
<VoteTally.Root
defaultValue={{
typescript: 87,
testing: 54,
docs: 72,
plugins: 43,
}}
className="flex flex-col gap-2"
>
{items.map((item) => (
<VoteTally.Item
key={item.id}
value={item.id}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent/50 transition-colors"
>
<VoteTally.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>
<VoteTally.Count className="text-xs font-medium" />
</VoteTally.Trigger>
<div className="flex flex-col">
<VoteTally.Title className="font-medium text-sm">{item.title}</VoteTally.Title>
<VoteTally.Description className="text-xs text-muted-foreground">{item.description}</VoteTally.Description>
</div>
</VoteTally.Item>
))}
</VoteTally.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 VoteTallyValue = Record<string, number>
export interface VoteTallyRootProps
extends Omit<ComponentProps<"ul">, "defaultValue"> {
/** Current vote counts (controlled) */
value?: VoteTallyValue
/** Initial vote counts (uncontrolled) */
defaultValue?: VoteTallyValue
/** Callback when votes change */
onValueChange?: (value: VoteTallyValue) => void
/** Set of item IDs the current user has voted for */
votedItems?: Set<string>
/** Default voted items (uncontrolled) */
defaultVotedItems?: Set<string>
/** Callback when user votes/unvotes */
onVotedItemsChange?: (votedItems: Set<string>) => void
/** Whether voting is disabled */
disabled?: boolean
}
export interface VoteTallyItemProps extends ComponentProps<"li"> {
/** Unique identifier for this item */
value: string
/** Whether this specific item is disabled */
disabled?: boolean
}
export type VoteTallyTriggerProps = ComponentProps<"button">
export type VoteTallyCountProps = ComponentProps<"span">
export type VoteTallyTitleProps = ComponentProps<"span">
export type VoteTallyDescriptionProps = ComponentProps<"span">
export interface VoteTallyGroupProps extends ComponentProps<"div"> {
/** Sort items by vote count */
sortBy?: "votes-asc" | "votes-desc" | "none"
}
/* -----------------------------------------------------------------------------
* Context
* -------------------------------------------------------------------------- */
interface VoteTallyContextValue {
votes: VoteTallyValue
votedItems: Set<string>
disabled: boolean
vote: (itemId: string) => void
unvote: (itemId: string) => void
toggleVote: (itemId: string) => void
getVoteCount: (itemId: string) => number
hasVoted: (itemId: string) => boolean
}
const VoteTallyContext = createContext<VoteTallyContextValue | null>(null)
function useVoteTallyContext() {
const context = useContext(VoteTallyContext)
if (!context) {
throw new Error("VoteTally components must be used within VoteTally.Root")
}
return context
}
interface VoteTallyItemContextValue {
itemId: string
disabled: boolean
}
const VoteTallyItemContext = createContext<VoteTallyItemContextValue | null>(
null
)
function useVoteTallyItemContext() {
const context = useContext(VoteTallyItemContext)
if (!context) {
throw new Error(
"VoteTally.Item sub-components must be used within VoteTally.Item"
)
}
return context
}
/* -----------------------------------------------------------------------------
* Root
* -------------------------------------------------------------------------- */
function VoteTallyRoot({
value: controlledValue,
defaultValue = {},
onValueChange,
votedItems: controlledVotedItems,
defaultVotedItems,
onVotedItemsChange,
disabled = false,
children,
...props
}: VoteTallyRootProps) {
const [votes, setVotes] = useControllableState<VoteTallyValue>({
prop: controlledValue,
defaultProp: defaultValue,
onChange: onValueChange,
})
const [votedItemsArray, setVotedItemsArray] = useControllableState({
prop: controlledVotedItems ? Array.from(controlledVotedItems) : undefined,
defaultProp: defaultVotedItems ? Array.from(defaultVotedItems) : [],
onChange: (arr) => onVotedItemsChange?.(new Set(arr)),
})
const votedItems = useMemo(() => new Set(votedItemsArray), [votedItemsArray])
const vote = useCallback(
(itemId: string) => {
if (disabled || votedItems.has(itemId)) {
return
}
setVotes((prev) => ({
...prev,
[itemId]: (prev?.[itemId] ?? 0) + 1,
}))
setVotedItemsArray((prev) => [...(prev ?? []), itemId])
},
[disabled, votedItems, setVotes, setVotedItemsArray]
)
const unvote = useCallback(
(itemId: string) => {
if (disabled || !votedItems.has(itemId)) {
return
}
setVotes((prev) => ({
...prev,
[itemId]: Math.max((prev?.[itemId] ?? 0) - 1, 0),
}))
setVotedItemsArray((prev) => (prev ?? []).filter((id) => id !== itemId))
},
[disabled, votedItems, setVotes, setVotedItemsArray]
)
const toggleVote = useCallback(
(itemId: string) => {
if (votedItems.has(itemId)) {
unvote(itemId)
} else {
vote(itemId)
}
},
[votedItems, vote, unvote]
)
const getVoteCount = useCallback(
(itemId: string) => votes?.[itemId] ?? 0,
[votes]
)
const hasVoted = useCallback(
(itemId: string) => votedItems.has(itemId),
[votedItems]
)
const contextValue = useMemo(
() => ({
votes: votes ?? {},
votedItems,
disabled,
vote,
unvote,
toggleVote,
getVoteCount,
hasVoted,
}),
[
votes,
votedItems,
disabled,
vote,
unvote,
toggleVote,
getVoteCount,
hasVoted,
]
)
return (
<VoteTallyContext.Provider value={contextValue}>
<ul
aria-label="Vote tally list"
data-disabled={disabled ? true : undefined}
{...props}
>
{children}
</ul>
</VoteTallyContext.Provider>
)
}
/* -----------------------------------------------------------------------------
* Group (optional sorting wrapper)
* -------------------------------------------------------------------------- */
function VoteTallyGroup({
sortBy = "none",
children,
...props
}: VoteTallyGroupProps) {
const { votes } = useVoteTallyContext()
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 VoteTallyItemProps).value
const bValue = (b.props as VoteTallyItemProps).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 VoteTallyItem({
value,
disabled: itemDisabled = false,
children,
...props
}: VoteTallyItemProps) {
const {
disabled: rootDisabled,
hasVoted,
getVoteCount,
} = useVoteTallyContext()
const disabled = rootDisabled || itemDisabled
const voted = hasVoted(value)
const voteCount = getVoteCount(value)
const itemContextValue = useMemo(
() => ({ itemId: value, disabled }),
[value, disabled]
)
return (
<VoteTallyItemContext.Provider value={itemContextValue}>
<li
data-disabled={disabled ? true : undefined}
data-item={value}
data-slot="vote-tally-item"
data-vote-count={voteCount}
data-voted={voted ? true : undefined}
{...props}
>
{children}
</li>
</VoteTallyItemContext.Provider>
)
}
/* -----------------------------------------------------------------------------
* Trigger
* -------------------------------------------------------------------------- */
function VoteTallyTrigger({
children,
onClick,
...props
}: VoteTallyTriggerProps) {
const { toggleVote, hasVoted, disabled: rootDisabled } = useVoteTallyContext()
const { itemId, disabled: itemDisabled } = useVoteTallyItemContext()
const disabled = rootDisabled || itemDisabled
const voted = hasVoted(itemId)
const handleClick = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (!(event.defaultPrevented || disabled)) {
toggleVote(itemId)
}
},
[onClick, disabled, toggleVote, itemId]
)
return (
<button
aria-label={voted ? "Remove vote" : "Vote"}
aria-pressed={voted}
data-slot="vote-tally-trigger"
data-state={voted ? "voted" : "idle"}
disabled={disabled}
onClick={handleClick}
type="button"
{...props}
>
{children}
</button>
)
}
/* -----------------------------------------------------------------------------
* Count
* -------------------------------------------------------------------------- */
function VoteTallyCount({ children, ...props }: VoteTallyCountProps) {
const { getVoteCount } = useVoteTallyContext()
const { itemId } = useVoteTallyItemContext()
const count = getVoteCount(itemId)
return (
<span data-slot="vote-tally-count" {...props}>
{children ?? count}
</span>
)
}
/* -----------------------------------------------------------------------------
* Title
* -------------------------------------------------------------------------- */
function VoteTallyTitle({ children, ...props }: VoteTallyTitleProps) {
return (
<span data-slot="vote-tally-title" {...props}>
{children}
</span>
)
}
/* -----------------------------------------------------------------------------
* Description
* -------------------------------------------------------------------------- */
function VoteTallyDescription({
children,
...props
}: VoteTallyDescriptionProps) {
return (
<span data-slot="vote-tally-description" {...props}>
{children}
</span>
)
}
/* -----------------------------------------------------------------------------
* Hook for external access
* -------------------------------------------------------------------------- */
export function useVoteTally() {
return useVoteTallyContext()
}
/* -----------------------------------------------------------------------------
* Export
* -------------------------------------------------------------------------- */
export const VoteTally = {
Root: VoteTallyRoot,
Group: VoteTallyGroup,
Item: VoteTallyItem,
Trigger: VoteTallyTrigger,
Count: VoteTallyCount,
Title: VoteTallyTitle,
Description: VoteTallyDescription,
}
export {
VoteTallyRoot,
VoteTallyGroup,
VoteTallyItem,
VoteTallyTrigger,
VoteTallyCount,
VoteTallyTitle,
VoteTallyDescription,
}Dependencies
@radix-ui/react-use-controllable-state
Source: Cult UI