caliper uidocs

Vote control

Free

Up and down votes with a rolling score. Choosing the same arrow again clears your vote; the owner saves it optimistically and can revert.

Installs components/ui/vote-control.tsxDepends on lucide-reactMotion none in component source
pnpm dlx shadcn@latest add https://caliperui.site/r/vote-control.json
  • Score 214
    Dark mode for the editor
  • Score 98
    Scheduled CSV exports
  • Score 57
    SSO for the Team plan
  • Score 214
    Horizontal, upvotes only

When to use #

Use it for

  • Feature requests, answers, ideas.
  • Ranking by the community.

Skip it when

  • Personal preference with no ranking; use reactions or a rating.

Installation #

terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/vote-control.json

Adds its 3 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above. It is built from applyVote and VoteControl.

example.tsx
import { useState } from "react"
import { VoteControl, type Vote } from "@/components/ui/vote-control"

const ideas = [
  { id: "dark", title: "Dark mode for the editor", score: 214 },
  { id: "csv", title: "Scheduled CSV exports", score: 98 },
  { id: "sso", title: "SSO for the Team plan", score: 57 },
];

export function VoteControlDemo() {
  const [votes, setVotes] = useState<Record<string, { vote: Vote; score: number }>>(Object.fromEntries(ideas.map((i) => [i.id, { vote: 0 as Vote, score: i.score }])));
  return (
    <ul className="grid w-full max-w-md gap-2">
      {ideas.map((idea) => (
        <li key={idea.id} className="flex items-center gap-3 rounded-surface border border-border bg-surface px-3 py-2">
          <VoteControl label={idea.title} vote={votes[idea.id].vote} score={votes[idea.id].score} onVote={(vote, score) => setVotes((v) => ({ ...v, [idea.id]: { vote, score } }))} />
          <span className="text-sm font-medium">{idea.title}</span>
        </li>
      ))}
      <li className="flex items-center gap-3 px-1 pt-2 text-sm text-muted-foreground">
        <VoteControl label="Compact" orientation="horizontal" downvotes={false} vote={votes.dark.vote === 1 ? 1 : 0} score={votes.dark.score} onVote={(vote, score) => setVotes((v) => ({ ...v, dark: { vote, score } }))} />
        Horizontal, upvotes only
      </li>
    </ul>
  );
}

API reference #

applyVote

component

No props of its own; it accepts the native props of its root element.

VoteControl

component
PropTypeDefaultDescription
score *number—Score including the viewer's current vote.
vote *Vote—
onVote *(next: Vote, score: number) => void—
label *string—What is being voted on, e.g. "Dark mode for the editor".
orientation"horizontal" | "vertical""vertical"
downvotesbooleantrue
classNamestring—

Accessibility #

It exposes group semantics, with aria-label, aria-pressed, aria-hidden and aria-live kept in step with state. Changes that matter are announced through a live region.

No custom key handling; Tab and the native activation keys do the work.

Motion #

It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.