{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radio-ballot-drop",
  "title": "Radio Ballot Drop",
  "description": "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.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/radio-ballot-drop/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\n\n// A single-choice input rendered as a paper ballot. Each option is a real\n// role=\"radio\" button (roving tabindex, arrow-key navigation — the standard\n// WAI-ARIA radiogroup pattern) styled as a paper slip; hovering an unselected\n// slip lifts its corner. Selecting one spawns a decorative ghost clone that\n// folds, flutters, and rises through the ballot box's slot before fading —\n// changing the vote also retracts the previous ghost back out. The real\n// option element never leaves the DOM (it just switches to its \"chosen\"\n// ink), so the control stays a fully functional, always-focusable radio\n// group; the paper-drop animation is a one-shot CSS keyframe flourish, not a\n// per-frame rAF simulation.\n\nexport type BallotOption = { value: string; label: string };\n\nconst DEFAULT_OPTIONS: BallotOption[] = [\n  { value: \"lisbon\", label: \"Lisbon\" },\n  { value: \"kyoto\", label: \"Kyoto\" },\n  { value: \"reykjavik\", label: \"Reykjavik\" },\n];\n\ntype Ghost = {\n  id: number;\n  label: string;\n  top: number;\n  left: number;\n  width: number;\n  height: number;\n  travel: number;\n  mode: \"drop\" | \"retract\";\n};\n\nlet ghostSeq = 0;\n\nexport function BallotDrop({\n  options = DEFAULT_OPTIONS,\n  label = \"Offsite location\",\n  defaultValue,\n  className = \"\",\n}: {\n  /** the radio options */\n  options?: BallotOption[];\n  /** accessible name for the radio group */\n  label?: string;\n  /** uncontrolled initial selected value */\n  defaultValue?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const reactId = useId();\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const boxRef = useRef<HTMLDivElement>(null);\n  const optionRefs = useRef<Record<string, HTMLButtonElement | null>>({});\n  const [selected, setSelected] = useState<string | undefined>(defaultValue);\n  const [ghosts, setGhosts] = useState<Ghost[]>([]);\n  const liveRef = useRef<HTMLDivElement>(null);\n  const reducedRef = useRef(false);\n\n  useEffect(() => {\n    reducedRef.current = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n  }, []);\n\n  const announce = useCallback((text: string) => {\n    if (liveRef.current) liveRef.current.textContent = text;\n  }, []);\n\n  const spawnGhost = useCallback((optionEl: HTMLButtonElement, mode: Ghost[\"mode\"]) => {\n    const wrap = wrapRef.current;\n    const box = boxRef.current;\n    if (!wrap || !box) return;\n    const wrapRect = wrap.getBoundingClientRect();\n    const optRect = optionEl.getBoundingClientRect();\n    const boxRect = box.getBoundingClientRect();\n    const top = optRect.top - wrapRect.top;\n    const left = optRect.left - wrapRect.left;\n    const travel = boxRect.bottom - optRect.top - optRect.height * 0.3;\n    const id = ++ghostSeq;\n    setGhosts((g) => [\n      ...g,\n      {\n        id,\n        label: optionEl.textContent ?? \"\",\n        top,\n        left,\n        width: optRect.width,\n        height: optRect.height,\n        travel: -travel,\n        mode,\n      },\n    ]);\n    return id;\n  }, []);\n\n  const removeGhost = (id: number) => setGhosts((g) => g.filter((x) => x.id !== id));\n\n  const choose = useCallback(\n    (value: string) => {\n      if (value === selected) return;\n      const prevValue = selected;\n      const el = optionRefs.current[value];\n      setSelected(value);\n      const opt = options.find((o) => o.value === value);\n      announce(prevValue ? `Vote changed to ${opt?.label}` : `Voted: ${opt?.label}`);\n      if (el) spawnGhost(el, \"drop\");\n      if (prevValue) {\n        const prevEl = optionRefs.current[prevValue];\n        if (prevEl) spawnGhost(prevEl, \"retract\");\n      }\n    },\n    [selected, options, announce, spawnGhost]\n  );\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const idx = options.findIndex((o) => o.value === (selected ?? options[0]?.value));\n    let next = -1;\n    if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = (idx - 1 + options.length) % options.length;\n    else if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = (idx + 1) % options.length;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = options.length - 1;\n    else return;\n    e.preventDefault();\n    const target = options[next];\n    if (target) {\n      choose(target.value);\n      optionRefs.current[target.value]?.focus();\n    }\n  };\n\n  return (\n    <div ref={wrapRef} className={`relative flex w-64 flex-col items-center ${className}`}>\n      <style>{`\n        @keyframes nsui-radio-ballot-drop {\n          0% { transform: translateY(0) rotate(0deg) scaleY(1); opacity: 1; }\n          18% { transform: translateY(calc(var(--nsui-travel) * 0.12)) rotate(-7deg) scaleY(0.9); opacity: 1; }\n          40% { transform: translateY(calc(var(--nsui-travel) * 0.55)) rotate(6deg) scaleY(0.82); opacity: 1; }\n          62% { transform: translateY(calc(var(--nsui-travel) * 0.85)) rotate(-4deg) scaleY(0.78); opacity: 0.85; }\n          100% { transform: translateY(var(--nsui-travel)) rotate(2deg) scaleY(0.72); opacity: 0; }\n        }\n        @keyframes nsui-ballot-retract {\n          0% { transform: translateY(var(--nsui-travel)) rotate(2deg) scaleY(0.72); opacity: 0; }\n          20% { transform: translateY(calc(var(--nsui-travel) * 0.7)) rotate(-5deg) scaleY(0.8); opacity: 0.7; }\n          55% { transform: translateY(calc(var(--nsui-travel) * 0.25)) rotate(4deg) scaleY(0.9); opacity: 0.6; }\n          100% { transform: translateY(0) rotate(0deg) scaleY(1); opacity: 0; }\n        }\n        @keyframes nsui-radio-ballot-drop-reduced {\n          0% { transform: translateY(0); opacity: 1; }\n          100% { transform: translateY(calc(var(--nsui-travel) * 0.4)); opacity: 0; }\n        }\n        @keyframes nsui-ballot-retract-reduced {\n          0% { transform: translateY(calc(var(--nsui-travel) * 0.4)); opacity: 0; }\n          100% { transform: translateY(0); opacity: 0; }\n        }\n      `}</style>\n\n      {/* the ballot box: hairline body with a slot notch facing the options below it */}\n      <div\n        ref={boxRef}\n        aria-hidden=\"true\"\n        className=\"relative z-10 h-14 w-44 rounded-t-[12px] border border-border bg-surface\"\n      >\n        <div className=\"absolute inset-x-0 -bottom-px flex justify-center\">\n          <div className=\"h-[3px] w-20 rounded-full border border-border bg-background\" />\n        </div>\n      </div>\n\n      {/* ghost overlay: decorative paper-drop clones, never focusable */}\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 z-20 overflow-visible\">\n        {ghosts.map((g) => (\n          <div\n            key={g.id}\n            onAnimationEnd={() => removeGhost(g.id)}\n            className=\"absolute rounded-[4px] border border-border bg-[#f4f2ec] shadow-[0_4px_10px_-4px_rgba(0,0,0,0.35)]\"\n            style={\n              {\n                top: g.top,\n                left: g.left,\n                width: g.width,\n                height: g.height,\n                \"--nsui-travel\": `${g.travel}px`,\n                animation: `${\n                  reducedRef.current\n                    ? g.mode === \"drop\"\n                      ? \"nsui-radio-ballot-drop-reduced\"\n                      : \"nsui-ballot-retract-reduced\"\n                    : g.mode === \"drop\"\n                      ? \"nsui-radio-ballot-drop\"\n                      : \"nsui-ballot-retract\"\n                } ${reducedRef.current ? 260 : 620}ms cubic-bezier(0.3,0.6,0.3,1) forwards`,\n              } as React.CSSProperties\n            }\n          >\n            <span className=\"flex h-full items-center justify-center px-2 font-mono text-[11px] text-[#171717]\">\n              {g.label}\n            </span>\n          </div>\n        ))}\n      </div>\n\n      <div\n        role=\"radiogroup\"\n        aria-label={label}\n        aria-describedby={`${reactId}-hint`}\n        onKeyDown={onKeyDown}\n        className=\"mt-6 flex w-full flex-col gap-2.5\"\n      >\n        {options.map((opt) => {\n          const checked = opt.value === selected;\n          return (\n            <button\n              key={opt.value}\n              ref={(el) => {\n                optionRefs.current[opt.value] = el;\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={checked}\n              tabIndex={checked || (!selected && opt.value === options[0]?.value) ? 0 : -1}\n              onClick={() => choose(opt.value)}\n              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\"\n            >\n              {/* peeling corner: lifts on hover of an unselected slip */}\n              <span\n                aria-hidden=\"true\"\n                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\"\n                style={{ clipPath: \"polygon(0 0, 100% 0, 100% 100%)\" }}\n              />\n              <span className=\"flex items-center justify-between gap-3\">\n                <span className=\"font-mono text-sm text-[#171717]\">{opt.label}</span>\n                <span\n                  aria-hidden=\"true\"\n                  className={`h-2 w-2 rounded-full border border-[#171717]/40 transition-colors ${\n                    checked ? \"bg-[#171717]\" : \"bg-transparent\"\n                  }`}\n                />\n              </span>\n            </button>\n          );\n        })}\n      </div>\n\n      <div ref={liveRef} aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\" />\n      <p className=\"sr-only\" id={`${reactId}-hint`}>\n        Use arrow keys to change your vote.\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/radio-ballot-drop.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "radio",
      "form",
      "input",
      "single-choice",
      "paper",
      "physics",
      "vote"
    ],
    "instruction": "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.",
    "rank": 287
  },
  "type": "registry:ui"
}