up or down with the score between them, the same press again taking it back
npx shadcn add @ag/voteAlready installed? Preview an update
npx shadcn add @ag/vote --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- Fixed:
shortScorepicks its unit after rounding (999,500 is "1m", not "1000k"), reads a down score as its up mirror, and throws on a score that is no whole number.
also called upvote, downvote
shelf ratings & feedback
uses button, field-context, material
used by components
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Vote, type VoteValue } from "@/registry/base-nova/ui/vote"
// #region optimistic
/** One vote, sent. The score moves with the press, before the answer; a refused
* vote puts back what the server last said, unless a newer press has spoken
* since. */
function useVote(start: { vote: VoteValue; score: number }, refuse: boolean) {
const [vote, setVote] = React.useState(start.vote)
const [score, setScore] = React.useState(start.score)
const [said, setSaid] = React.useState("")
const confirmed = React.useRef(start)
const latest = React.useRef(0)
const change = (next: VoteValue) => {
const turn = ++latest.current
setScore(score - vote + next)
setVote(next)
setSaid("sending")
send(next, refuse).then(
() => {
confirmed.current = { vote: next, score: score - vote + next }
if (turn === latest.current) setSaid("saved")
},
() => {
if (turn !== latest.current) return
setVote(confirmed.current.vote)
setScore(confirmed.current.score)
setSaid("refused, put back")
},
)
}
return { vote, score, change, said }
}
/** A stand-in server: answers after a moment, or refuses. */
const send = (_vote: VoteValue, refuse: boolean) =>
new Promise<void>((done, fail) =>
setTimeout(() => (refuse ? fail(new Error("refused")) : done()), 700),
)
// #endregion
// #region post
function Post({ refuse = false }: { refuse?: boolean }) {
const v = useVote({ vote: 0, score: 41 }, refuse)
return (
<div className="flex items-start gap-3">
<Vote
aria-label="vote on this post"
value={v.vote}
score={v.score}
onValueChange={v.change}
/>
<div className="flex flex-col gap-1 pt-1">
<span className="font-medium text-sm">
{refuse ? "a server that refuses" : "a server that agrees"}
</span>
<span className="font-mono text-[11px] text-current/55">
{v.said || "press an arrow, then the same one again"}
</span>
</div>
</div>
)
}
// #endregion
// #region both
function Both() {
const [vote, setVote] = React.useState<VoteValue>(1)
const [score, setScore] = React.useState(1284)
const change = (next: VoteValue) => {
setScore(score - vote + next)
setVote(next)
}
return (
<div className="flex items-center gap-6">
<Vote value={vote} score={score} onValueChange={change} />
<Vote
orientation="horizontal"
value={vote}
score={score}
onValueChange={change}
/>
</div>
)
}
// #endregion
// #region thread
const COMMENTS = [
{ by: "lark", text: "the half stars read better than I expected", score: 12 },
{ by: "wren", text: "keyboard first, as always", score: 3 },
{ by: "tern", text: "what about a locked thread?", score: -2 },
]
/** Hacker News's density: the arrows and the number alone, beside each line. */
function Thread() {
const [votes, setVotes] = React.useState<Record<string, VoteValue>>({})
return (
<ul className="flex flex-col gap-2">
{COMMENTS.map((c) => {
const vote = votes[c.by] ?? 0
return (
<li key={c.by} className="flex items-center gap-2">
<Vote
compact
size="sm"
orientation="horizontal"
aria-label={`vote on ${c.by}'s comment`}
value={vote}
score={c.score + vote}
onValueChange={(next) =>
setVotes((all) => ({ ...all, [c.by]: next }))
}
/>
<span className="text-[13px]">
<span className="font-mono text-current/55">{c.by}</span> {c.text}
</span>
</li>
)
})}
</ul>
)
}
// #endregion
export default function VoteDemo() {
const [vote, setVote] = React.useState<VoteValue>(0)
return (
<div className="space-y-8">
<Sample name="glance" glance title="up, down, take back">
<Vote
value={vote}
score={41 + vote}
onValueChange={setVote}
aria-label="vote on this answer"
/>
</Sample>
<Sample
name="optimistic"
title="the score moves first"
description="the handler moves the score with the vote, before the server answers; a refusal puts back what the server last agreed to, unless a newer press has spoken since. the score is said politely when it changes."
with="optimistic post"
>
<div className="flex flex-col gap-5">
<Post />
<Post refuse />
</div>
</Sample>
<Sample
name="materials"
title="plain and glass"
description="vertical beside a post, horizontal under one; a sunken well plain, a pill of glass."
with="both"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="p-6">
<Both />
</div>
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6 text-white">
<Both />
</div>
</MaterialProvider>
</div>
</Sample>
<Sample name="compact" title="compact" with="thread">
<Thread />
</Sample>
<Sample
name="read-only"
title="read-only"
description="a locked thread: your vote and the score shown, the arrows kept in the Tab order and read out (pressed, read-only), a press changing nothing. never dimmed, which is disabled."
>
<div className="flex items-center gap-6">
<Vote value={1} score={88} readOnly aria-label="vote, locked" />
<Vote value={0} score={7} disabled aria-label="vote, off" />
</div>
</Sample>
<Sample name="sizes" title="three sizes">
<div className="flex items-center gap-4">
{(["sm", "md", "lg"] as const).map((size) => (
<Vote
key={size}
size={size}
orientation="horizontal"
value={0}
score={42}
aria-label={`${size} vote`}
/>
))}
</div>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import { ArrowBigDown, ArrowBigUp } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button, type ButtonSize } from "@/registry/base-nova/ui/button"
import { useField } from "@/registry/base-nova/ui/field-context"
import {
type Material,
MaterialProvider,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
// UP OR DOWN, AND WHERE IT STANDS (Reddit, Stack Overflow, Hacker News): two toggle
// buttons with the score between them. One vote at a time: up, down, or none, and
// pressing the one you gave again takes it back. Controlled: the consumer holds the
// vote (`value`: 1, -1, 0) and the score, and moves the score in the same handler
// that records the vote, before any answer comes back (optimistic), so the number
// follows the press at once; a refusal puts both back.
//
// <Vote value={vote} score={score} onValueChange={(next) => {
// setScore((s) => s - vote + next); setVote(next); send(next)
// }} />
//
// Each arrow is a `Button` with `aria-pressed` on a fixed name ("upvote"); the score
// is said politely when it changes (a live region with the exact number, the eye
// reading it short: 12.3k). Vertical (beside a post) or horizontal (under a comment);
// in a well by default, plain a sunken one and glass a pill of glass, or `compact`,
// the arrows and the number alone. `readOnly` (a locked thread): the vote and the
// score shown, the buttons kept in the Tab order and read out, a press changing
// nothing. Inside a field it is named and described by it and held still while the
// field is read-only.
export type VoteValue = -1 | 0 | 1
export type VoteSize = "sm" | "md" | "lg"
const ARROW: Record<VoteSize, ButtonSize> = {
sm: "icon-sm",
md: "icon",
lg: "icon-lg",
}
const SCORE: Record<VoteSize, string> = {
sm: "text-[12px]",
md: "text-[13px]",
lg: "text-sm",
}
/** The well holding them: a sunken strip plain, a pill of glass (its radius half
* of the arrows' width, the narrow side). */
const PLAIN_WELL = "rounded-full bg-foreground/[0.06] p-0.5"
const GLASS_WELL: Record<VoteSize, string> = {
sm: "p-0.5 [--glass-radius:16px]",
md: "p-0.5 [--glass-radius:18px]",
lg: "p-0.5 [--glass-radius:22px]",
}
/** A score as the eye reads it: whole up to a thousand, then 1.2k, 12k, 1.2m. */
export function shortScore(score: number): string {
if (!Number.isInteger(score))
throw new Error(`vote: a score is a whole number, got ${score}`)
if (Math.abs(score) < 1000) return String(score)
// Rounded on its size, the sign put back: a down score reads as its up mirror.
const short = (n: number) => {
const a = Math.abs(n)
return Math.sign(n) * (a < 100 ? Math.round(a * 10) / 10 : Math.round(a))
}
// The unit follows the number as it is shown: 999,500 is "1m", never "1000k".
const k = short(score / 1000)
return Math.abs(k) < 1000 ? `${k}k` : `${short(score / 1e6)}m`
}
export interface VoteProps {
/** Yours: 1 up, -1 down, 0 none. */
value: VoteValue
/** Where it stands, your vote included. */
score: number
/** The vote a press makes: the other arrow's, or 0 when it takes yours back. */
onValueChange?: (value: VoteValue) => void
orientation?: "vertical" | "horizontal"
/** The arrows and the number alone, with no well round them. */
compact?: boolean
size?: VoteSize
/** The score as the eye reads it; the reader always hears the whole number. */
format?: (score: number) => string
/** Shown, never changed: a press changes nothing, the buttons keep their Tab
* stops. Never dimmed (that is `disabled`). */
readOnly?: boolean
disabled?: boolean
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
/** What the vote is on ("vote on this answer"); "vote" unless given or a field
* names it. */
"aria-label"?: string
"aria-describedby"?: string
className?: string
ref?: React.Ref<HTMLFieldSetElement>
}
export function Vote({
value,
score,
onValueChange,
orientation = "vertical",
compact = false,
size = "md",
format = shortScore,
readOnly,
disabled,
material,
scheme,
"aria-label": label,
"aria-describedby": describedBy,
className,
ref,
}: VoteProps) {
if (value !== -1 && value !== 0 && value !== 1)
throw new Error(`Vote: value is 1, -1 or 0, got ${value}`)
if (!Number.isInteger(score))
throw new Error(`Vote: score is a whole number, got ${score}`)
const m = useMaterial(material)
const [s, here] = useSurfaceScheme<HTMLFieldSetElement>(scheme, ref)
const field = useField()
const held = (readOnly ?? false) || (field?.readOnly ?? false)
const off = disabled ?? field?.wiring.disabled
const id = React.useId()
const lockedId = `${id}-locked`
const described =
[describedBy ?? field?.wiring["aria-describedby"], held && lockedId]
.filter(Boolean)
.join(" ") || undefined
const press = (side: 1 | -1) => {
if (held) return
onValueChange?.(value === side ? 0 : side)
}
const glass = m === "glass"
const arrow = (side: 1 | -1) => {
const on = value === side
const Glyph = side === 1 ? ArrowBigUp : ArrowBigDown
return (
<Button
variant="ghost"
size={ARROW[size]}
corners="pill"
material={m}
pressed={on}
disabled={off}
aria-label={side === 1 ? "upvote" : "downvote"}
aria-describedby={described}
data-slot={side === 1 ? "vote-up" : "vote-down"}
onClick={() => press(side)}
className={cn(
on ? "text-current" : "text-current/60 hover:text-current",
held && "cursor-default hover:bg-transparent",
)}
>
<Glyph aria-hidden className={cn(on && "fill-current")} />
</Button>
)
}
return (
<MaterialProvider material={m} scheme={s}>
<fieldset
ref={here}
aria-label={label ?? (field ? undefined : "vote")}
aria-labelledby={label ? undefined : field?.labelId}
data-slot="vote"
data-material={m}
data-orientation={orientation}
data-value={value === 1 ? "up" : value === -1 ? "down" : "none"}
data-readonly={held ? "" : undefined}
className={cn(
"inline-flex w-fit min-w-0 shrink-0 items-center",
orientation === "vertical" ? "flex-col" : "flex-row",
schemeOf(s),
!compact &&
(glass
? [glassVariants({ shape: "surface" }), GLASS_WELL[size]]
: PLAIN_WELL),
className,
)}
>
{arrow(1)}
<output
data-slot="vote-score"
aria-live="polite"
aria-atomic
className={cn(
"min-w-[3ch] text-center font-medium tabular-nums",
orientation === "horizontal" && "px-0.5",
SCORE[size],
value === 0 ? "text-current/75" : "text-current",
)}
>
<span aria-hidden>{format(score)}</span>
<span className="sr-only">score {score}</span>
</output>
{arrow(-1)}
{held && (
<span id={lockedId} className="sr-only">
read-only
</span>
)}
</fieldset>
</MaterialProvider>
)
}