"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export type Vote = -1 | 0 | 1;
type Spring = { stiffness: number; damping: number; mass: number };
export interface VoteControlProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onError" | "children"> {
/** Score *without* the viewer's own vote. The control shows `score + vote`. */
score?: number;
/** Controlled vote: 1 (up), -1 (down) or 0 (none). */
vote?: Vote;
/** Initial vote in uncontrolled mode. */
defaultVote?: Vote;
/** Called immediately with every optimistic change, and again with the previous vote on rollback. */
onVoteChange?: (vote: Vote) => void;
/**
* Persist the vote. The UI updates optimistically first; if the returned promise rejects, the vote
* rolls back (unless a newer vote has been cast since).
*/
onVote?: (next: Vote, previous: Vote) => Promise<unknown> | unknown;
/** Receives the rejection reason after a rollback. */
onError?: (error: unknown) => void;
/** Stack the arrows above and below the score, or place them side by side. */
orientation?: "vertical" | "horizontal";
/** Colour of an active upvote. */
upColor?: string;
/** Colour of an active downvote. */
downColor?: string;
/** Duration of each digit roll, in ms. */
rollDuration?: number;
/** Spring for the arrow bounce. */
spring?: Spring;
/** Abbreviate large scores (12.4K). */
compact?: boolean;
/** BCP 47 locale used to format the score. */
locale?: string;
/** Noun used in the accessible group name, e.g. "post" → "Vote on this post". */
itemLabel?: string;
}
/* --------------------------------- helpers --------------------------------- */
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/**
* A number whose changed characters roll in the direction of the change: up when the score rises,
* down when it falls. Characters are keyed by their position from the right, so only digits that
* actually change animate.
*/
function RollingScore({ text, direction, duration, instant }: { text: string; direction: 1 | -1; duration: number; instant: boolean }) {
const chars = text.split("");
return (
<span aria-hidden="true" className="inline-flex tabular-nums">
{chars.map((char, i) => (
<span key={chars.length - i} className="relative inline-flex justify-center overflow-hidden">
<AnimatePresence initial={false} mode="popLayout" custom={direction}>
<motion.span
key={char}
custom={direction}
className="inline-block"
variants={{
enter: (d: number) => ({ y: instant ? 0 : `${d * 100}%`, opacity: instant ? 1 : 0 }),
center: { y: "0%", opacity: 1 },
exit: (d: number) => ({ y: instant ? 0 : `${d * -100}%`, opacity: 0 }),
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: instant ? 0 : duration / 1000, ease: [0.22, 1, 0.36, 1] }}
>
{char}
</motion.span>
</AnimatePresence>
</span>
))}
</span>
);
}
const ARROW = "M8 2.25 13.5 8.25H10.25V13.25H5.75V8.25H2.5Z";
function Arrow({ direction, active, bump, spring, instant }: { direction: 1 | -1; active: boolean; bump: number; spring: Spring; instant: boolean }) {
return (
<motion.svg
// Remounting on each bump replays the pop; the first render stays still.
key={bump}
viewBox="0 0 16 16"
aria-hidden="true"
className="size-[18px] overflow-visible"
initial={bump > 0 && !instant ? { y: direction * -5, scale: 1.22 } : false}
animate={{ y: 0, scale: 1 }}
transition={{ type: "spring", ...spring }}
style={{ rotate: direction === -1 ? 180 : 0 }}
>
<path
d={ARROW}
strokeWidth={1.5}
strokeLinejoin="round"
className={`stroke-current transition-[fill] duration-200 ${active ? "fill-current" : "fill-transparent"}`}
/>
</motion.svg>
);
}
/**
* Forum-style up / down voting. Votes apply optimistically, the score rolls digit by digit in the
* direction of the change, and a failed `onVote` rolls the vote back with a small shake.
*/
export function VoteControl({
score = 128,
vote: voteProp,
defaultVote = 0,
onVoteChange,
onVote,
onError,
orientation = "vertical",
upColor = "#FF7448",
downColor = "#5B8DEF",
rollDuration = 380,
spring = { stiffness: 520, damping: 14, mass: 0.6 },
compact = false,
locale = "en-US",
itemLabel = "post",
className = "",
style,
...props
}: VoteControlProps) {
const reduceMotion = usePrefersReducedMotion();
const [internalVote, setInternalVote] = React.useState<Vote>(defaultVote);
const vote = voteProp ?? internalVote;
const total = score + vote;
const [bumps, setBumps] = React.useState({ up: 0, down: 0 });
const [shakes, setShakes] = React.useState(0);
const [message, setMessage] = React.useState("");
const requestRef = React.useRef(0);
// Remember which way the score last moved so the digits roll the right way.
const [previousTotal, setPreviousTotal] = React.useState(total);
const [direction, setDirection] = React.useState<1 | -1>(1);
if (total !== previousTotal) {
setPreviousTotal(total);
setDirection(total > previousTotal ? 1 : -1);
}
const formatter = new Intl.NumberFormat(locale, compact ? { notation: "compact", maximumFractionDigits: 1 } : undefined);
const display = formatter.format(total);
const setVote = (next: Vote) => {
if (voteProp === undefined) setInternalVote(next);
onVoteChange?.(next);
};
const cast = async (pressed: 1 | -1) => {
const previous = vote;
const next: Vote = previous === pressed ? 0 : pressed;
setVote(next);
if (next === 1) setBumps((b) => ({ ...b, up: b.up + 1 }));
if (next === -1) setBumps((b) => ({ ...b, down: b.down + 1 }));
const nextTotal = formatter.format(score + next);
setMessage(next === 1 ? `Upvoted. Score ${nextTotal}` : next === -1 ? `Downvoted. Score ${nextTotal}` : `Vote removed. Score ${nextTotal}`);
if (!onVote) return;
const id = ++requestRef.current;
try {
await onVote(next, previous);
} catch (error) {
// A newer vote supersedes this one; only the latest request may roll back.
if (id !== requestRef.current) return;
setVote(previous);
setShakes((n) => n + 1);
setMessage(`Vote could not be saved. Score ${formatter.format(score + previous)}`);
onError?.(error);
}
};
const vertical = orientation === "vertical";
const tone = vote === 1 ? "text-[var(--vc-up-ink)]" : vote === -1 ? "text-[var(--vc-down-ink)]" : "text-zinc-900 dark:text-zinc-100";
const button = (dir: 1 | -1) => {
const active = vote === dir;
return (
<button
type="button"
aria-pressed={active}
aria-label={`${dir === 1 ? "Upvote" : "Downvote"}, current score ${display}`}
onClick={() => void cast(dir)}
className={`inline-flex size-8 shrink-0 items-center justify-center rounded-full outline-hidden transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--vc-ring)] ${
active
? dir === 1
? "bg-[color-mix(in_oklab,var(--vc-up)_14%,transparent)] text-[var(--vc-up)]"
: "bg-[color-mix(in_oklab,var(--vc-down)_14%,transparent)] text-[var(--vc-down)]"
: dir === 1
? "text-zinc-500 hover:bg-zinc-900/[0.06] hover:text-[var(--vc-up)] dark:text-zinc-400 dark:hover:bg-white/[0.07]"
: "text-zinc-500 hover:bg-zinc-900/[0.06] hover:text-[var(--vc-down)] dark:text-zinc-400 dark:hover:bg-white/[0.07]"
}`}
>
<Arrow direction={dir} active={active} bump={dir === 1 ? bumps.up : bumps.down} spring={spring} instant={reduceMotion} />
</button>
);
};
return (
<div
role="group"
aria-label={`Vote on this ${itemLabel}`}
data-vote={vote}
className={`inline-flex ${className}`}
style={{
["--vc-up" as string]: upColor,
["--vc-down" as string]: downColor,
// Score ink mixes the vote colour with the text colour so pale colours stay legible.
["--vc-up-ink" as string]: `color-mix(in oklab, ${upColor} 80%, currentColor)`,
["--vc-down-ink" as string]: `color-mix(in oklab, ${downColor} 80%, currentColor)`,
["--vc-ring" as string]: vote === -1 ? downColor : upColor,
...style,
}}
{...props}
>
<motion.div
key={shakes}
initial={shakes > 0 && !reduceMotion ? { x: vertical ? 0 : -4, y: vertical ? -3 : 0 } : false}
animate={{ x: 0, y: 0 }}
transition={{ type: "spring", stiffness: 900, damping: 9, mass: 0.5 }}
className={`inline-flex items-center rounded-full border border-zinc-900/10 bg-white/80 p-1 text-zinc-900 shadow-[0_1px_0_rgba(0,0,0,0.03)] dark:border-white/10 dark:bg-white/[0.04] dark:text-zinc-100 ${
vertical ? "flex-col gap-0.5" : "flex-row gap-1"
}`}
>
{button(1)}
<span
className={`inline-flex min-w-[3ch] justify-center text-sm font-semibold tracking-tight transition-colors duration-200 ${tone} ${vertical ? "py-0.5" : "px-0.5"}`}
>
<RollingScore text={display} direction={direction} duration={rollDuration} instant={reduceMotion} />
<span className="sr-only">{`Score ${display}`}</span>
</span>
{button(-1)}
</motion.div>
<span role="status" aria-live="polite" className="sr-only">
{message}
</span>
</div>
);
}