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…

"use client"

import * as React from "react"
import { ArrowBigDown, ArrowBigUp, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * Arrow press feedback and the shake that reports a refused vote.
 * React 19 hoisted <style> — dedupes by href, no Tailwind config edits.
 */
const KEYFRAMES = `@keyframes vb-pop{0%{transform:scale(1)}40%{transform:scale(0.86)}70%{transform:scale(1.14)}100%{transform:scale(1)}}
@keyframes vb-shake{0%,100%{transform:translateX(0)}20%,60%{transform:translateX(-2px)}40%,80%{transform:translateX(2px)}}`

export type VoteDirection = "up" | "down"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/vote-buttons.json

Prompt

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 totalbaseScore deliberately excludes the viewer's own point, which turns the displayed number into the pure function baseScore + 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 / -1 makes “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-pressed all 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.

On This Page