# Vote Tally

> Composable upvote list for React and shadcn/ui with accessible vote buttons, sorting by count, and controlled or uncontrolled state.

Source: https://www.cult-ui.com/docs/components/vote-tally

## Example

```tsx title="vote-tally-demo.tsx"
"use client"

import { useState } from "react"
import { ArrowUp } from "lucide-react"

import { cn } from "@/lib/utils"

import { VoteTally, type VoteTallyValue } from "../ui/vote-tally"

/* -----------------------------------------------------------------------------
 * Example: Styled Vote Tally Widget
 * Demonstrates usage of the headless VoteTally primitive
 * -------------------------------------------------------------------------- */

const FEATURES = [
  {
    id: "dark-mode",
    title: "Dark Mode",
    description: "Add system-wide dark mode support with automatic detection",
  },
  {
    id: "keyboard-shortcuts",
    title: "Keyboard Shortcuts",
    description: "Customizable keyboard shortcuts for power users",
  },
  {
    id: "export-pdf",
    title: "Export to PDF",
    description: "Export documents and reports as PDF files",
  },
  {
    id: "api-access",
    title: "API Access",
    description: "Public API for third-party integrations",
  },
  {
    id: "mobile-app",
    title: "Mobile App",
    description: "Native iOS and Android applications",
  },
] as const

export function VoteTallyExample() {
  const [votes, setVotes] = useState<VoteTallyValue>({
    "dark-mode": 142,
    "keyboard-shortcuts": 89,
    "export-pdf": 67,
    "api-access": 203,
    "mobile-app": 156,
  })

  const [votedItems, setVotedItems] = useState<Set<string>>(
    new Set(["dark-mode"])
  )

  return (
    <div className="w-full max-w-md">
      <VoteTally.Root
        className="flex flex-col gap-2"
        onValueChange={setVotes}
        onVotedItemsChange={setVotedItems}
        value={votes}
        votedItems={votedItems}
      >
        <VoteTally.Group className="flex flex-col gap-2" sortBy="votes-desc">
          {FEATURES.map((feature) => (
            <VoteTally.Item
              className={cn(
                "border-border bg-background flex items-start gap-3 rounded-lg border p-3",
                "data-voted:bg-muted",
                "data-disabled:cursor-not-allowed data-disabled:opacity-50"
              )}
              key={feature.id}
              value={feature.id}
            >
              <VoteTally.Trigger
                className={cn(
                  "flex shrink-0 flex-col items-center gap-0.5 rounded-md border px-2 py-1.5",
                  "border-border bg-background text-muted-foreground",
                  "hover:bg-muted hover:text-foreground",
                  "focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
                  "data-[state=voted]:bg-muted data-[state=voted]:text-foreground",
                  "disabled:pointer-events-none disabled:opacity-50"
                )}
              >
                <ArrowUp aria-hidden className="size-4" />
                <VoteTally.Count className="text-xs font-medium tabular-nums" />
              </VoteTally.Trigger>

              <div className="flex min-w-0 flex-col gap-0.5">
                <VoteTally.Title className="text-foreground text-sm font-medium text-balance">
                  {feature.title}
                </VoteTally.Title>
                <VoteTally.Description className="text-muted-foreground text-sm text-pretty">
                  {feature.description}
                </VoteTally.Description>
              </div>
            </VoteTally.Item>
          ))}
        </VoteTally.Group>
      </VoteTally.Root>
    </div>
  )
}

/* -----------------------------------------------------------------------------
 * Example: Minimal/Compact variant
 * -------------------------------------------------------------------------- */

export function VoteTallyCompact() {
  return (
    <div className="w-full max-w-sm">
      <VoteTally.Root
        className="flex flex-col gap-1"
        defaultValue={{
          "feature-a": 12,
          "feature-b": 8,
          "feature-c": 24,
        }}
      >
        {[
          { id: "feature-a", title: "Inline editing" },
          { id: "feature-b", title: "Batch operations" },
          { id: "feature-c", title: "Auto-save drafts" },
        ].map((feature) => (
          <VoteTally.Item
            className={cn(
              "flex items-center justify-between gap-2 rounded px-2 py-1.5",
              "hover:bg-muted",
              "data-voted:bg-muted"
            )}
            key={feature.id}
            value={feature.id}
          >
            <VoteTally.Title className="text-foreground text-sm">
              {feature.title}
            </VoteTally.Title>

            <VoteTally.Trigger
              className={cn(
                "flex items-center gap-1 rounded px-1.5 py-0.5 text-xs",
                "text-muted-foreground hover:text-foreground",
                "data-[state=voted]:text-foreground data-[state=voted]:font-medium"
              )}
            >
              <ArrowUp aria-hidden className="size-3" />
              <VoteTally.Count className="tabular-nums" />
            </VoteTally.Trigger>
          </VoteTally.Item>
        ))}
      </VoteTally.Root>
    </div>
  )
}

export default function VoteTallyDemo() {
  return (
    <div className="flex w-full max-w-lg flex-col gap-10 py-6">
      <div className="border-border bg-card rounded-lg border p-4">
        <VoteTallyExample />
      </div>
      <div className="border-border bg-card rounded-lg border p-4">
        <VoteTallyCompact />
      </div>
    </div>
  )
}
```

Vote Tally is a composable React component for shadcn/ui. Use it for feature request boards, polls, roadmap feedback, or community idea lists.

## Installation

### CLI

```bash
npx shadcn@latest add @cult-ui/vote-tally
```

### Manual

**Copy and paste the following code into your project.**

```tsx title="vote-tally.tsx"
"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,
}
```

**Update the import paths to match your project setup.**

## Usage

Use `VoteTally.Root` with `VoteTally.Item` for each item; each item can include Trigger, Title, Description, and Count. Supports controlled or uncontrolled vote counts and voted-items state, and optional sorting via `VoteTally.Group`.

### Basic list

```tsx
import { VoteTally } from "@/registry/default/ui/vote-tally";

export default function Example() {
  return (
    <VoteTally.Root>
      <VoteTally.Item value="dark-mode">
        <VoteTally.Title>Dark mode</VoteTally.Title>
        <VoteTally.Description>System-aware dark theme</VoteTally.Description>
        <VoteTally.Count />
        <VoteTally.Trigger>Vote</VoteTally.Trigger>
      </VoteTally.Item>
      <VoteTally.Item value="keyboard-shortcuts">
        <VoteTally.Title>Keyboard shortcuts</VoteTally.Title>
        <VoteTally.Description>Customizable hotkeys</VoteTally.Description>
        <VoteTally.Count />
        <VoteTally.Trigger>Vote</VoteTally.Trigger>
      </VoteTally.Item>
    </VoteTally.Root>
  );
}
```

### Controlled votes and voted items

Wire `value` / `onValueChange` and `votedItems` / `onVotedItemsChange` to sync with your backend or state.

```tsx
import { useState } from "react";
import { VoteTally } from "@/registry/default/ui/vote-tally";

export default function Example() {
  const [votes, setVotes] = useState({
    "dark-mode": 10,
    "keyboard-shortcuts": 5,
  });
  const [voted, setVoted] = useState(new Set<string>());

  return (
    <VoteTally.Root
      value={votes}
      onValueChange={setVotes}
      votedItems={voted}
      onVotedItemsChange={setVoted}
    >
      <VoteTally.Item value="dark-mode">
        <VoteTally.Title>Dark mode</VoteTally.Title>
        <VoteTally.Count />
        <VoteTally.Trigger>Vote</VoteTally.Trigger>
      </VoteTally.Item>
      <VoteTally.Item value="keyboard-shortcuts">
        <VoteTally.Title>Keyboard shortcuts</VoteTally.Title>
        <VoteTally.Count />
        <VoteTally.Trigger>Vote</VoteTally.Trigger>
      </VoteTally.Item>
    </VoteTally.Root>
  );
}
```

### With sorting by votes

Wrap items in `VoteTally.Group` with `sortBy="votes-desc"` or `sortBy="votes-asc"` to reorder by vote count.

```tsx
import { VoteTally } from "@/registry/default/ui/vote-tally";

export default function Example() {
  return (
    <VoteTally.Root defaultValue={{ a: 2, b: 5, c: 1 }}>
      <VoteTally.Group sortBy="votes-desc">
        <VoteTally.Item value="a">
          <VoteTally.Title>Option A</VoteTally.Title>
          <VoteTally.Count />
          <VoteTally.Trigger>Vote</VoteTally.Trigger>
        </VoteTally.Item>
        <VoteTally.Item value="b">
          <VoteTally.Title>Option B</VoteTally.Title>
          <VoteTally.Count />
          <VoteTally.Trigger>Vote</VoteTally.Trigger>
        </VoteTally.Item>
        <VoteTally.Item value="c">
          <VoteTally.Title>Option C</VoteTally.Title>
          <VoteTally.Count />
          <VoteTally.Trigger>Vote</VoteTally.Trigger>
        </VoteTally.Item>
      </VoteTally.Group>
    </VoteTally.Root>
  );
}
```

## Features

- Vote and unvote per item via Trigger; one vote per user per item (tracked by `votedItems`)
- Controlled or uncontrolled: `value` / `defaultValue` and `onValueChange` for vote counts; `votedItems` / `defaultVotedItems` and `onVotedItemsChange` for which items the user voted for
- Optional sorting: `VoteTally.Group` with `sortBy="votes-asc"` or `sortBy="votes-desc"`
- Compound API: Root, Group, Item, Trigger, Count, Title, Description
- `useVoteTally()` hook for external access to vote state and actions
