← ui

vote

ui

up or down with the score between them, the same press again taking it back

npx shadcn add @ag/vote
Already installed? Preview an update
npx shadcn add @ag/vote --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-12
  • Fixed: shortScore picks 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.
Updated 2026-10-12 · release notes

also called upvote, downvote

shelf ratings & feedback

uses button, field-context, material

used by components

the demo above, verbatim

vote.demo.tsx
"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

vote.tsx
"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>
  )
}