Skip to content
WANCRAFT

A.125Buttons & Controls

Vote Control

Forum-style up / down voting with rolling digits, bouncing arrows and optimistic updates that roll back on error.

NewReactMotionSVGTailwindintermediate

Overview

Vote Control is the up / down score you know from forums, built with care. Pressing an arrow fills it and pops it with a spring, and the score rolls digit by digit in the direction of the change — only the digits that actually change move. Pressing the same arrow again removes the vote; switching from up to down moves the score by two. Votes apply optimistically: pass an async onVote and, if it rejects, the control rolls back to the previous vote with a small shake and tells screen-reader users what happened. The arrows are real toggle buttons with aria-pressed and names that include the current score, and every change is announced through a polite live region. Stack it vertically beside a post or lay it out horizontally in a toolbar.

01Installation

Method 01 · Registry

CLI verified

Uses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/vote-control.tsx and adds its dependencies.

npx shadcn@latest add https://wancraft.my/r/vote-control.json

Method 02 · Copy and paste

  1. 1. Install the dependencies below.
  2. 2. Copy the source from the Code tab into components/ui/vote-control.tsx.
  3. 3. Import it and adjust props.
npm install motion
Dependencies
motion
Peer
react ≥ 18, tailwindcss ≥ 4
Added
13 Oct 2026
Updated
—

02Usage

The default configuration. Change properties above to generate a tailored example in the Usage tab.

example.tsx
import { VoteControl } from "@/components/ui/vote-control";

export function VoteControlExample() {
  return (
    <VoteControl
      onVote={(next) => fetch(`/api/vote?value=${next}`, { method: "POST" })}
    />
  );
}

03Props

PropTypeDefaultDescription
scorenumber128Score without the viewer's own vote; the control displays score + vote.
vote-1 | 0 | 1—Controlled vote.
defaultVote-1 | 0 | 10Initial vote in uncontrolled mode.
onVoteChange(vote: Vote) => void—Every optimistic change, and the restored vote on rollback.
onVote(next: Vote, previous: Vote) => Promise<unknown> | unknown—Persist the vote. A rejection rolls back to the previous vote (unless a newer vote was cast).
onError(error: unknown) => void—Receives the rejection reason after a rollback.
orientation"vertical" | "horizontal""vertical"Arrows above and below the score, or side by side.
upColor / downColorstring"#FF7448" / "#5B8DEF"Colours of active votes (the score is tinted to match).
rollDurationnumber380Duration of each digit roll in ms.
spring{ stiffness: number; damping: number; mass: number }{ stiffness: 520, damping: 14, mass: 0.6 }Spring for the arrow pop.
compactbooleanfalseAbbreviate large scores (12.4K).
localestring"en-US"Locale for number formatting.
itemLabelstring"post"Noun in the group's accessible name ("Vote on this post").
...propsHTMLAttributes<HTMLDivElement>—Forwarded to the root group element.

04Features

  • 01Toggle semantics: the same arrow removes a vote, the other flips it (score changes by 2)
  • 02Digits roll up or down with the change; unchanged digits stay still
  • 03Arrows fill and pop on a configurable spring
  • 04Optimistic updates with automatic rollback when onVote rejects; stale failures are ignored
  • 05Vertical and horizontal layouts; optional compact numbers
  • 06Controlled or uncontrolled vote
motion · springmotion · tweeninput · clickinput · keyboard

05Accessibility

  • Both arrows are native buttons with aria-pressed and names that include the score ("Upvote, current score 128").
  • The control is a labelled group ("Vote on this post"); the visual digits are hidden and the score is exposed once as text.
  • A polite live region announces each change ("Upvoted. Score 129") and failed saves.
  • Active votes differ by fill, not colour alone, and focus-visible rings follow the active colour.
  • With prefers-reduced-motion digits swap instantly and the arrows and rollback shake do not move.

06Limitations

  • score excludes the viewer's vote — if your API returns a total that includes it, subtract the vote first.