Vote control
FreeUp and down votes with a rolling score. Choosing the same arrow again clears your vote; the owner saves it optimistically and can revert.
pnpm dlx shadcn@latest add https://caliperui.site/r/vote-control.json- Score 214Dark mode for the editor
- Score 98Scheduled CSV exports
- Score 57SSO for the Team plan
- Score 214Horizontal, 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 #
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.
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
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.