my/ui

Command Palette

Search for a command to run...

All components

Vote Tally

data-display

List 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.json

Usage

"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