Vote Buttons
Feedback

Vote Buttons

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.

Install

npx shadcn@latest add @paragon/vote-buttons

Also installs: digit-roll

vote-buttons.tsx

"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>
  );
}