Buttons
Vote Buttons
An up and down vote pair with the score between them — exclusive directions, optimistic score maths where a swap is worth two points, and a rollback when the server refuses.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/vote-buttons.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VoteButtons" component using lucide-react
(ArrowBigUp / ArrowBigDown). No other runtime dependency.
Contract
- type VoteDirection = "up" | "down"
- weight(null) = 0, weight("up") = +1, weight("down") = -1. Every number the
component shows comes out of this one table.
- forwardRef<HTMLDivElement> on a role="group" wrapper that spreads the rest of
React.HTMLAttributes<HTMLDivElement>; props:
baseScore: number the score WITHOUT the viewer's own vote. Given an
API payload of { total, myVote }, the caller
passes total - weight(myVote).
vote?: VoteDirection | null controlled direction (null = nothing cast)
defaultVote?: VoteDirection | null uncontrolled seed (default null)
onVoteChange?: (vote, ctx) => void | PromiseLike<unknown>
variant?: "compact" | "roomy" density only (default "roomy")
label?: string folded into the accessible names
formatScore?: (score: number) => string
disabled?: boolean
- ctx = { previous, delta, score }: the direction that was active before the
press, the points this press moves the score by, and the score already on
screen. delta is what a PATCH should send; score is what a PUT should send.
- Displayed score = baseScore + weight(current direction). It is a pure
function, which is the whole reason baseScore excludes the viewer: an
optimistic vote and a fresh server total can never double count each other.
- Also export the default formatter so callers can wrap it.
Behavior
- Exclusive pair. Pressing the inactive arrow swaps sides; pressing the active
one retracts to null. There is no third button and no "both".
- The ±2 of a swap is emergent, not a special case: weight("up") -
weight("down") = 2 falls out of the table, so cast/retract move 1 and a swap
moves 2 with no branch anywhere.
- Optimistic overlay. The press updates an overlay that holds a *direction*, not
an accumulated number, and the score re-derives from baseScore + that
direction. A fresher baseScore arriving mid-flight slides in underneath and
the ±1 is still applied exactly once.
- Promise contract. onVoteChange may return a promise; while it is unsettled the
overlay stays and the group carries aria-busy. Resolving commits the direction
(into internal state when uncontrolled) and drops the overlay; rejecting drops
the overlay — which *is* the rollback, since the score and aria-pressed both
re-derive — and raises a failed flag. A callback that returns nothing is
treated as settled immediately.
- Newest press wins. A request-id ref is read and bumped synchronously inside
the handler before anything can await; a settle whose id is stale returns
without touching state. Double-tapping mid-flight can therefore never resurrect
the earlier direction.
- Unmount safety. An alive ref, set true on mount and false in the effect
cleanup (set on the way in too, or StrictMode's pre-mount cleanup would
silence every later settle), guards both settle paths. The component owns no
timers, listeners or observers — the only async thing in it is the consumer's
promise, and it can only lose the race, never leak.
- The failed flag clears on the next press, so a retry visibly resets the
control instead of stacking error states.
- Keyboard: both arrows are ordinary buttons and ordinary tab stops — Tab and
Shift+Tab move between them, Enter or Space activates, Enter or Space on the
active one retracts. No roving tabindex: neither arrow is the other's
fallback, and an arrow-key group would make a keyboard user pass through a
vote they did not want.
- disabled is inert, not removed: aria-disabled="true" on both buttons plus a
guard as the first line of the handler. Never the native disabled attribute —
the browser blurs a node the instant it becomes disabled, and this control
goes inert underneath the user when a thread archives or a session expires.
- Score formatting: exact under 10k, then one truncated decimal ("12.3k",
"1.2m"), sign preserved ("-12.3k"). Truncated toward zero, never rounded up,
so a score never reads as having crossed a milestone it has not crossed. Past
10k the collapsed text can hold still while the exact score moves — which is
precisely why the announcement carries the exact number.
Rendering & styling
- Semantic tokens only: bg-card + border for the pill, bg-primary/10 +
text-primary for an active upvote, bg-destructive/10 + text-destructive for an
active downvote, text-muted-foreground → hover:bg-muted hover:text-foreground
when idle, ring-destructive for the failed state, ring-ring for focus. No
hardcoded colours — dark mode comes free.
- cn() merges every className, including the consumer's onto the root.
- Two @keyframes shipped through a React 19 hoisted <style href="..."
precedence="medium"> tag, so many rows on one page dedupe to a single rule:
vb-pop (the pressed arrow squashes then overshoots, restarted by re-keying
that arrow's wrapper) and vb-shake (the score twitches once when a vote is
refused). Under prefers-reduced-motion neither counter is bumped at all — the
decoration is never spawned rather than hidden, and the state, the score and
the announcement are identical either way.
- ARIA contract: role="group" with an aria-label naming the subject; one
aria-pressed per button so the pair reports "which side am I on" rather than
"what happens next"; the accessible name of each button stays a stable verb
("Upvote this comment") and never flips to "Remove upvote", because a name
describing the next action while aria-pressed says true announces two
contradictory things. The visible number is aria-hidden and a sr-only
role="status" aria-live="polite" aria-atomic region carries the full sentence
("129 points, upvoted" / "128 points, vote not saved"). That sentence is
derived from render state, so it announces on every change — a press, a
rollback, a server refresh — and stays browsable; live regions do not announce
their initial content, so mounting is silent.
- Density variant maps to four class slots (wrapper padding/gap, button size,
icon size, score min-width/text-size). Both variants render the same DOM, the
same roles and the same behaviour; only the numbers differ.
Customization levers
- Colour roles: the down arrow uses destructive; swap it for var(--chart-2) or
text-muted-foreground if a downvote should read as neutral rather than
dangerous. The up arrow's bg-primary/10 pair is the one place brand colour
enters.
- Density: add a third row to the density record (e.g. "dense" for table rows);
nothing else needs to know about it.
- Layout: the wrapper is a flex row; switch it to flex-col for a Reddit-style
post rail. Order stays up / score / down either way.
- Formatting: pass formatScore to group with separators, to localise through
Intl.NumberFormat, or to hide the number entirely and keep the two arrows.
- Threshold: the 10k collapse point and the "k"/"m" suffixes are two constants.
- Motion: drop vb-shake for a colour-only failure, or replace vb-pop with a
translate for a lighter press.
- Failure surface: the failed flag currently paints ring-destructive; route it
to a toast instead by reacting to your own promise rejection — the component
rolls back either way.Concepts
- Base score, not total —
baseScoredeliberately excludes the viewer's own point, which turns the displayed number into the pure functionbaseScore + weight(direction); a server refresh and an in-flight vote then compose instead of fighting. - The swap is worth two — a weight table of
+1 / 0 / -1makes “change sides” cost two points and “cast or retract” cost one, with no branch that could ever get the sign wrong. - Overlay, not accumulator — optimistic state stores the direction being attempted rather than a running number, so an interrupted or corrected round trip can never leave the score one point off forever.
- Newest press wins — a request id is read and bumped in the same synchronous tick as the press, so a stale settle recognises itself and returns without touching state.
- Rollback is a deletion — refusing a vote does not compute a compensating number; it drops the overlay, and the score, the tone and
aria-pressedall re-derive from what the server still believes. - Collapse the digits, keep the number — past 10k the visible text is a summary and can hold still across a real change, so the exact score lives in the polite live region where it can never be rounded away.
Follow Button
A follow / unfollow toggle with the hover-to-unfollow reveal, an optimistic follower count that rolls back when the request is refused, and a busy state that never uses native disabled.
Rating
A star rating that is both an accessible input (whole or half steps) and a fractional read-only display.