Skip to main content

ns-ui

Radio Ballot Drop

A single-choice input as a paper ballot: the chosen option folds into a slip and drops through the ballot box's slot with two-phase paper physics (flutter, then settle), and switching your vote pulls the old slip back out first.

Usage

registry/core/radio-ballot-drop/demo.tsx
"use client";

import { useEffect, useRef } from "react";
import { BallotDrop } from "./component";

// Self-driving: the ballot is a real uncontrolled radio input (clicking is
// the only API), so the script dispatches real clicks on the rendered radio
// buttons on a timer — first casting a vote, then changing it, so the gate's
// screenshot can catch a settled slip and the demo exercises the retract +
// drop path on its own, with no pointer or keyboard input required.
const SCRIPT = [
  { index: 0, delay: 900 },
  { index: 1, delay: 2600 },
];

export default function BallotDropDemo() {
  const wrapRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const timers = SCRIPT.map(({ index, delay }) =>
      setTimeout(() => {
        const radios = wrapRef.current?.querySelectorAll<HTMLButtonElement>('[role="radio"]');
        radios?.[index]?.click();
      }, delay)
    );
    return () => timers.forEach(clearTimeout);
  }, []);

  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-8 bg-background px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / radio-ballot-drop
      </p>
      <div ref={wrapRef}>
        <BallotDrop />
      </div>
      <p className="max-w-xs text-center text-xs text-ns-muted">
        Pick a slip — it folds and drops through the slot. Choosing another
        pulls the old one back out first.
      </p>
    </main>
  );
}

Component

registry/core/radio-ballot-drop/component.tsx
"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";

// A single-choice input rendered as a paper ballot. Each option is a real
// role="radio" button (roving tabindex, arrow-key navigation — the standard
// WAI-ARIA radiogroup pattern) styled as a paper slip; hovering an unselected
// slip lifts its corner. Selecting one spawns a decorative ghost clone that
// folds, flutters, and rises through the ballot box's slot before fading —
// changing the vote also retracts the previous ghost back out. The real
// option element never leaves the DOM (it just switches to its "chosen"
// ink), so the control stays a fully functional, always-focusable radio
// group; the paper-drop animation is a one-shot CSS keyframe flourish, not a
// per-frame rAF simulation.

export type BallotOption = { value: string; label: string };

const DEFAULT_OPTIONS: BallotOption[] = [
  { value: "lisbon", label: "Lisbon" },
  { value: "kyoto", label: "Kyoto" },
  { value: "reykjavik", label: "Reykjavik" },
];

type Ghost = {
  id: number;
  label: string;
  top: number;
  left: number;
  width: number;
  height: number;
  travel: number;
  mode: "drop" | "retract";
};

let ghostSeq = 0;

export function BallotDrop({
  options = DEFAULT_OPTIONS,
  label = "Offsite location",
  defaultValue,
  className = "",
}: {
  /** the radio options */
  options?: BallotOption[];
  /** accessible name for the radio group */
  label?: string;
  /** uncontrolled initial selected value */
  defaultValue?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}) {
  const reactId = useId();
  const wrapRef = useRef<HTMLDivElement>(null);
  const boxRef = useRef<HTMLDivElement>(null);
  const optionRefs = useRef<Record<string, HTMLButtonElement | null>>({});
  const [selected, setSelected] = useState<string | undefined>(defaultValue);
  const [ghosts, setGhosts] = useState<Ghost[]>([]);
  const liveRef = useRef<HTMLDivElement>(null);
  const reducedRef = useRef(false);

  useEffect(() => {
    reducedRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  }, []);

  const announce = useCallback((text: string) => {
    if (liveRef.current) liveRef.current.textContent = text;
  }, []);

  const spawnGhost = useCallback((optionEl: HTMLButtonElement, mode: Ghost["mode"]) => {
    const wrap = wrapRef.current;
    const box = boxRef.current;
    if (!wrap || !box) return;
    const wrapRect = wrap.getBoundingClientRect();
    const optRect = optionEl.getBoundingClientRect();
    const boxRect = box.getBoundingClientRect();
    const top = optRect.top - wrapRect.top;
    const left = optRect.left - wrapRect.left;
    const travel = boxRect.bottom - optRect.top - optRect.height * 0.3;
    const id = ++ghostSeq;
    setGhosts((g) => [
      ...g,
      {
        id,
        label: optionEl.textContent ?? "",
        top,
        left,
        width: optRect.width,
        height: optRect.height,
        travel: -travel,
        mode,
      },
    ]);
    return id;
  }, []);

  const removeGhost = (id: number) => setGhosts((g) => g.filter((x) => x.id !== id));

  const choose = useCallback(
    (value: string) => {
      if (value === selected) return;
      const prevValue = selected;
      const el = optionRefs.current[value];
      setSelected(value);
      const opt = options.find((o) => o.value === value);
      announce(prevValue ? `Vote changed to ${opt?.label}` : `Voted: ${opt?.label}`);
      if (el) spawnGhost(el, "drop");
      if (prevValue) {
        const prevEl = optionRefs.current[prevValue];
        if (prevEl) spawnGhost(prevEl, "retract");
      }
    },
    [selected, options, announce, spawnGhost]
  );

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const idx = options.findIndex((o) => o.value === (selected ?? options[0]?.value));
    let next = -1;
    if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + options.length) % options.length;
    else if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % options.length;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = options.length - 1;
    else return;
    e.preventDefault();
    const target = options[next];
    if (target) {
      choose(target.value);
      optionRefs.current[target.value]?.focus();
    }
  };

  return (
    <div ref={wrapRef} className={`relative flex w-64 flex-col items-center ${className}`}>
      <style>{`
        @keyframes nsui-radio-ballot-drop {
          0% { transform: translateY(0) rotate(0deg) scaleY(1); opacity: 1; }
          18% { transform: translateY(calc(var(--nsui-travel) * 0.12)) rotate(-7deg) scaleY(0.9); opacity: 1; }
          40% { transform: translateY(calc(var(--nsui-travel) * 0.55)) rotate(6deg) scaleY(0.82); opacity: 1; }
          62% { transform: translateY(calc(var(--nsui-travel) * 0.85)) rotate(-4deg) scaleY(0.78); opacity: 0.85; }
          100% { transform: translateY(var(--nsui-travel)) rotate(2deg) scaleY(0.72); opacity: 0; }
        }
        @keyframes nsui-ballot-retract {
          0% { transform: translateY(var(--nsui-travel)) rotate(2deg) scaleY(0.72); opacity: 0; }
          20% { transform: translateY(calc(var(--nsui-travel) * 0.7)) rotate(-5deg) scaleY(0.8); opacity: 0.7; }
          55% { transform: translateY(calc(var(--nsui-travel) * 0.25)) rotate(4deg) scaleY(0.9); opacity: 0.6; }
          100% { transform: translateY(0) rotate(0deg) scaleY(1); opacity: 0; }
        }
        @keyframes nsui-radio-ballot-drop-reduced {
          0% { transform: translateY(0); opacity: 1; }
          100% { transform: translateY(calc(var(--nsui-travel) * 0.4)); opacity: 0; }
        }
        @keyframes nsui-ballot-retract-reduced {
          0% { transform: translateY(calc(var(--nsui-travel) * 0.4)); opacity: 0; }
          100% { transform: translateY(0); opacity: 0; }
        }
      `}</style>

      {/* the ballot box: hairline body with a slot notch facing the options below it */}
      <div
        ref={boxRef}
        aria-hidden="true"
        className="relative z-10 h-14 w-44 rounded-t-[12px] border border-border bg-surface"
      >
        <div className="absolute inset-x-0 -bottom-px flex justify-center">
          <div className="h-[3px] w-20 rounded-full border border-border bg-background" />
        </div>
      </div>

      {/* ghost overlay: decorative paper-drop clones, never focusable */}
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 z-20 overflow-visible">
        {ghosts.map((g) => (
          <div
            key={g.id}
            onAnimationEnd={() => removeGhost(g.id)}
            className="absolute rounded-[4px] border border-border bg-[#f4f2ec] shadow-[0_4px_10px_-4px_rgba(0,0,0,0.35)]"
            style={
              {
                top: g.top,
                left: g.left,
                width: g.width,
                height: g.height,
                "--nsui-travel": `${g.travel}px`,
                animation: `${
                  reducedRef.current
                    ? g.mode === "drop"
                      ? "nsui-radio-ballot-drop-reduced"
                      : "nsui-ballot-retract-reduced"
                    : g.mode === "drop"
                      ? "nsui-radio-ballot-drop"
                      : "nsui-ballot-retract"
                } ${reducedRef.current ? 260 : 620}ms cubic-bezier(0.3,0.6,0.3,1) forwards`,
              } as React.CSSProperties
            }
          >
            <span className="flex h-full items-center justify-center px-2 font-mono text-[11px] text-[#171717]">
              {g.label}
            </span>
          </div>
        ))}
      </div>

      <div
        role="radiogroup"
        aria-label={label}
        aria-describedby={`${reactId}-hint`}
        onKeyDown={onKeyDown}
        className="mt-6 flex w-full flex-col gap-2.5"
      >
        {options.map((opt) => {
          const checked = opt.value === selected;
          return (
            <button
              key={opt.value}
              ref={(el) => {
                optionRefs.current[opt.value] = el;
              }}
              type="button"
              role="radio"
              aria-checked={checked}
              tabIndex={checked || (!selected && opt.value === options[0]?.value) ? 0 : -1}
              onClick={() => choose(opt.value)}
              className="group relative overflow-visible rounded-[6px] border border-border bg-[#f4f2ec] px-4 py-3 text-left outline-none transition-[transform,box-shadow] duration-150 ease-out motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              {/* peeling corner: lifts on hover of an unselected slip */}
              <span
                aria-hidden="true"
                className="absolute right-0 top-0 h-4 w-4 origin-top-right rounded-bl-[6px] bg-[#e7e3d8] shadow-[0_1px_2px_rgba(0,0,0,0.15)] transition-transform duration-200 ease-out group-hover:-translate-y-0.5 group-hover:translate-x-0.5 group-hover:rotate-[8deg] motion-reduce:transition-none"
                style={{ clipPath: "polygon(0 0, 100% 0, 100% 100%)" }}
              />
              <span className="flex items-center justify-between gap-3">
                <span className="font-mono text-sm text-[#171717]">{opt.label}</span>
                <span
                  aria-hidden="true"
                  className={`h-2 w-2 rounded-full border border-[#171717]/40 transition-colors ${
                    checked ? "bg-[#171717]" : "bg-transparent"
                  }`}
                />
              </span>
            </button>
          );
        })}
      </div>

      <div ref={liveRef} aria-live="polite" aria-atomic="true" className="sr-only" />
      <p className="sr-only" id={`${reactId}-hint`}>
        Use arrow keys to change your vote.
      </p>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/radio-ballot-drop.tsx, so point the import there.

Use when a single-choice form input (radio group) that deserves a tactile, physical selection moment — a survey, a poll, a settings choice among a handful of named options — rather than a plain radio list or segmented control; not for counting or tallying events (see histogram-live-grain for that).

Install

npx shadcn add https://design.helpmarq.com/r/radio-ballot-drop.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build a functioning single-choice input (a real WAI-ARIA radiogroup: an outer role="radiogroup" wrapping one role="radio" button per option, aria-checked reflecting selection, roving tabindex — only the checked option, or the first when none is checked, has tabIndex 0, the rest -1 — and ArrowLeft/Up, ArrowRight/Down, Home, End all move both focus and selection, exactly the standard radiogroup keyboard pattern) styled as a paper ballot. Render a small ballot-box glyph above the option list: a hairline-bordered rounded-top rectangle (border-border, bg-surface) with a short pill-shaped "slot" notch bridging its bottom edge, facing the options below it. Each option is a paper-slip button: a fixed off-white paper color (not a theme token — real paper reads the same in both themes, matching the registry's precedent for deliberately fixed non-token colors) with dark ink text, a small hairline-bordered corner square clipped to a triangle in the top-right that lifts (translate + slight rotate, 200ms ease-out) on :hover via a group-hover transform — this is what makes the hover state differ from default. A small filled/hollow dot at the option's trailing edge shows checked state. Selecting an option does NOT remove it from the DOM — the real button stays put and just switches its checked dot — instead it spawns a decorative aria-hidden "ghost" clone (absolutely positioned over the whole component, measured via getBoundingClientRect from the clicked option to the ballot box) that plays a one-shot CSS keyframe: fold + flutter (alternating small rotate wobbles while scaleY compresses toward ~0.72-0.9, translateY riding a CSS custom property --nsui-travel computed as the pixel distance from the option to the box's slot) then settle as it fades to opacity 0 partway through the rise — a discrete keyframe animation, not a per-frame physics loop, since this is a bounded one-shot flourish. Changing an existing vote spawns a SECOND ghost for the previously-selected option playing the retract keyframe (the same shape in reverse-ish motion, ending at opacity 0 by the time it reaches the option's base position so it never visibly doubles the real, already-updated unselected slip) at the same time the new choice's drop ghost plays. An aria-live="polite" aria-atomic sr-only region announces "Voted: <label>" on first selection and "Vote changed to <label>" thereafter. Core restraint: zero color flourish beyond the standard focus-visible ring (focus-visible:ring-2 focus-visible:ring-ns-accent, using a ring not an outline utility, paired with a plain outline-none base — never pair a base outline-none with a focus-visible:outline utility on the same element, since Tailwind resolves that combination to an invisible ring) — no accent anywhere else, hairline borders only. Reduced motion: skip the flutter keyframes entirely and use a short linear fade (opacity 1 to 0 over ~260ms, translateY moving only a fraction of the travel distance) for both drop and retract, still legible as "the slip left" without any rotation or scale wobble.

Props

PropTypeDefaultDescription
options?BallotOption[]DEFAULT_OPTIONSthe radio options
label?string"Offsite location"accessible name for the radio group
defaultValue?string—uncontrolled initial selected value
className?string—extra classes merged onto the rendered root element