A Reddit/Linear-style vote cluster. Arrows pop and fill on press, the score rolls odometer-style between values, and updates are optimistic: if the save rejects, the vote reverts, the count shakes once, and the failure is announced.
npx shadcn@latest add @paragon/vote-buttonsAlso installs: digit-roll
"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useAnimate,
useReducedMotion,
} from "motion/react";
import { ArrowBigDown, ArrowBigUp } from "lucide-react";
import { DigitRoll } from "@/registry/paragon/ui/digit-roll";
import { cn } from "@/lib/utils";
export type Vote = -1 | 0 | 1;
export interface VoteButtonsProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
/** Current score, with your `defaultVote` (if any) already counted. */
count?: number;
defaultVote?: Vote;
/** "expanded" stacks the cluster vertically; "compact" is a pill. */
variant?: "expanded" | "compact";
/**
* Called with the next vote. The UI updates optimistically — return a
* rejecting promise and the component reverts the vote, shakes the
* count, and announces the failure.
*/
onVoteChange?: (vote: Vote) => void | Promise<void>;
/** Disable both arrows (e.g. archived thread). */
disabled?: boolean;
/** Swap states instantly instead of animating. */
static?: boolean;
}
const swapSpring = { type: "spring", duration: 0.3, bounce: 0 } as const;
function VoteArrow({
direction,
active,
disabled,
instant,
isStatic,
onClick,
}: {
direction: 1 | -1;
active: boolean;
disabled?: boolean;
instant: boolean;
isStatic: boolean;
onClick: () => void;
}) {
const Icon = direction === 1 ? ArrowBigUp : ArrowBigDown;
return (
<button
type="button"
disabled={disabled}
aria-label={direction === 1 ? "Upvote" : "Downvote"}
aria-pressed={active}
onClick={onClick}
className={cn(
"relative inline-flex size-8 items-center justify-center rounded-md outline-none",
"transition-[color,scale] duration-(--duration-quick) ease-(--ease-out)",
"focus-visible:ring-2 focus-visible:ring-ring",
"disabled:pointer-events-none disabled:opacity-50",
!isStatic && "active:not-disabled:scale-[0.9]",
active ? "text-primary" : "text-muted-foreground hover:text-foreground",
// The visual is 32px; extend the hit target to 40px.
"after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={active ? "filled" : "outline"}
className="inline-flex"
initial={
instant
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
animate={
instant
? { opacity: 1 }
: { opacity: 1, scale: 1, filter: "blur(0px)" }
}
exit={
instant
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
transition={swapSpring}
>
<Icon
aria-hidden
className="size-5"
fill={active ? "currentColor" : "none"}
/>
</motion.span>
</AnimatePresence>
</button>
);
}
/**
* Reddit/Linear-style vote cluster. Arrows pop and fill on press, the score
* rolls odometer-style between values, and updates are optimistic: if
* `onVoteChange` rejects, the vote reverts, the count shakes once, and the
* failure is announced to screen readers. Compact (horizontal pill) and
* expanded (vertical) variants.
*/
export function VoteButtons({
count = 0,
defaultVote = 0,
variant = "expanded",
onVoteChange,
disabled = false,
static: isStatic = false,
className,
...props
}: VoteButtonsProps) {
const reducedMotion = useReducedMotion();
const instant = isStatic || !!reducedMotion;
const [vote, setVote] = React.useState<Vote>(defaultVote);
const [pending, setPending] = React.useState(false);
const [message, setMessage] = React.useState("");
const requestSeq = React.useRef(0);
const [scoreScope, animateScore] = useAnimate<HTMLSpanElement>();
// `count` includes defaultVote; layer the live vote on top of the base.
const score = count - defaultVote + vote;
const cast = (direction: 1 | -1) => {
const previous = vote;
const next: Vote = vote === direction ? 0 : direction;
setVote(next);
setMessage(
next === 0
? `Vote removed — score ${count - defaultVote}`
: `${next === 1 ? "Upvoted" : "Downvoted"} — score ${
count - defaultVote + next
}`,
);
const result = onVoteChange?.(next);
if (result && typeof (result as Promise<void>).then === "function") {
const seq = ++requestSeq.current;
setPending(true);
(result as Promise<void>)
.catch(() => {
if (seq !== requestSeq.current) return;
setVote(previous);
setMessage("Couldn't save your vote — reverted.");
if (!instant && scoreScope.current) {
animateScore(
scoreScope.current,
{ x: [0, -4, 4, -2, 2, 0] },
{ duration: 0.3, ease: "easeOut" },
);
}
})
.finally(() => {
if (seq === requestSeq.current) setPending(false);
});
}
};
return (
<div
data-slot="vote-buttons"
aria-busy={pending || undefined}
className={cn(
variant === "compact"
? "inline-flex h-9 items-center rounded-full bg-card px-0.5 shadow-border"
: "inline-flex w-9 flex-col items-center gap-0.5 py-0.5",
className,
)}
{...props}
>
<span aria-live="polite" className="sr-only">
{message}
</span>
<VoteArrow
direction={1}
active={vote === 1}
disabled={disabled}
instant={instant}
isStatic={isStatic}
onClick={() => cast(1)}
/>
<motion.span
ref={scoreScope}
className={cn(
"text-center text-sm font-medium tabular-nums select-none",
"transition-[color,opacity] duration-(--duration-quick) ease-(--ease-out)",
vote !== 0 ? "text-primary" : "text-foreground",
pending && "opacity-60",
variant === "compact" ? "min-w-6 px-0.5" : "min-w-full",
)}
>
<DigitRoll value={score} static={instant} />
</motion.span>
<VoteArrow
direction={-1}
active={vote === -1}
disabled={disabled}
instant={instant}
isStatic={isStatic}
onClick={() => cast(-1)}
/>
</div>
);
}