{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "quorum",
  "type": "registry:ui",
  "title": "Quorum",
  "description": "A segmented control: 2–5 options with a single shared-layout pill that springs behind the active choice while its label ignites to full contrast and a faint accent bloom pulses on settle. Real radio semantics — role=radiogroup/radio, roving tabindex, Arrow/Home/End keyboard, focus-visible. Controlled or uncontrolled. The pill snaps under reduced motion.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/inputs/quorum/quorum.tsx",
      "type": "registry:ui",
      "target": "components/crucible/quorum.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nexport interface QuorumOption {\n  /** Unique, stable value identifying this option. */\n  value: string;\n  /** Visible label. */\n  label: React.ReactNode;\n  /** Disable this option — skipped by pointer and keyboard navigation. */\n  disabled?: boolean;\n}\n\nexport interface QuorumProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"onChange\" | \"defaultValue\" | \"role\"\n  > {\n  /** The options, in order. 2–5 reads best. */\n  options: QuorumOption[];\n  /** Controlled selected value. Omit for uncontrolled use (see `defaultValue`). */\n  value?: string;\n  /** Initial selected value when uncontrolled. @default first enabled option */\n  defaultValue?: string;\n  /** Fires whenever the selection changes, controlled or not. */\n  onChange?: (value: string) => void;\n  /**\n   * Accessible name for the group. Provide this OR an external label via\n   * `aria-labelledby`. @default \"Choose an option\"\n   */\n  \"aria-label\"?: string;\n  /**\n   * The single cool accent — tints the focus ring and the settle bloom.\n   * @default sky\n   */\n  accent?: string;\n  /**\n   * Disable the whole control (all options unselectable). Individual options\n   * can be disabled via `QuorumOption.disabled`. @default false\n   */\n  disabled?: boolean;\n  /** Freeze the spring — the pill still moves, but snaps. @default false */\n  paused?: boolean;\n  /** Visual density. @default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\";\n  className?: string;\n}\n\n/** One cool accent on an otherwise neutral-elegant control. */\nconst DEFAULT_ACCENT = \"#38bdf8\";\n\nconst SIZES = {\n  sm: \"gap-0.5 p-0.5 text-[13px]\",\n  md: \"gap-1 p-1 text-sm\",\n  lg: \"gap-1 p-1.5 text-[15px]\",\n} as const;\n\nconst OPTION_SIZES = {\n  sm: \"px-3 py-1\",\n  md: \"px-4 py-1.5\",\n  lg: \"px-5 py-2\",\n} as const;\n\n/**\n * Quorum — a segmented control. A row of 2–5 options with a single shared-layout\n * pill that springs behind the active option (via `layoutId`); the chosen label\n * ignites from muted neutral to full contrast and a faint accent bloom pulses as\n * the pill settles. Real radio semantics: `role=\"radiogroup\"` wrapping\n * `role=\"radio\"` buttons with roving tabindex, Arrow/Home/End keyboard, and\n * `focus-visible` rings. Controlled or uncontrolled. Under reduced motion the\n * pill snaps instantly (no spring) and the settle bloom is suppressed — the\n * resting selected state is fully styled, never blank.\n */\nexport function Quorum({\n  options,\n  value,\n  defaultValue,\n  onChange,\n  \"aria-label\": ariaLabel = \"Choose an option\",\n  \"aria-labelledby\": ariaLabelledby,\n  accent = DEFAULT_ACCENT,\n  disabled = false,\n  paused = false,\n  size = \"md\",\n  className,\n  ...props\n}: QuorumProps) {\n  const reducedMotion = useReducedMotion();\n  const baseId = React.useId();\n  const optionRefs = React.useRef<Array<HTMLButtonElement | null>>([]);\n  // Bumped each time the pill finishes settling into an option; retriggers the\n  // accent bloom pulse. Stays 0 on first paint so the control mounts at rest\n  // with no flash.\n  const [landCount, setLandCount] = React.useState(0);\n\n  const firstEnabled =\n    options.find((option) => !option.disabled)?.value ?? options[0]?.value;\n  const [internalValue, setInternalValue] = React.useState(\n    defaultValue ?? firstEnabled\n  );\n  const activeValue = value ?? internalValue;\n\n  const select = React.useCallback(\n    (next: string) => {\n      if (value === undefined) setInternalValue(next);\n      onChange?.(next);\n    },\n    [value, onChange]\n  );\n\n  const nextEnabledIndex = React.useCallback(\n    (from: number, direction: 1 | -1) => {\n      const count = options.length;\n      let index = from;\n      for (let step = 0; step < count; step++) {\n        index = (index + direction + count) % count;\n        if (!options[index]?.disabled) return index;\n      }\n      return from;\n    },\n    [options]\n  );\n\n  const focusAndSelect = React.useCallback(\n    (index: number) => {\n      const option = options[index];\n      if (!option || option.disabled) return;\n      select(option.value);\n      optionRefs.current[index]?.focus();\n    },\n    [options, select]\n  );\n\n  const handleKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      const currentIndex = options.findIndex((o) => o.value === activeValue);\n\n      let nextIndex: number | null = null;\n      if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\")\n        nextIndex = nextEnabledIndex(currentIndex, 1);\n      else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\")\n        nextIndex = nextEnabledIndex(currentIndex, -1);\n      else if (event.key === \"Home\") nextIndex = nextEnabledIndex(-1, 1);\n      else if (event.key === \"End\") nextIndex = nextEnabledIndex(0, -1);\n\n      if (nextIndex === null) return;\n      event.preventDefault();\n      focusAndSelect(nextIndex);\n    },\n    [disabled, options, activeValue, nextEnabledIndex, focusAndSelect]\n  );\n\n  // Roving tabindex: exactly one focusable stop. The selected option owns it;\n  // if it is disabled or no option matches the value, the first enabled does.\n  const selectedIndex = options.findIndex(\n    (o) => o.value === activeValue && !o.disabled\n  );\n  const tabStop =\n    selectedIndex !== -1 ? selectedIndex : options.findIndex((o) => !o.disabled);\n\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={ariaLabelledby ? undefined : ariaLabel}\n      aria-labelledby={ariaLabelledby}\n      aria-disabled={disabled || undefined}\n      data-crucible=\"quorum\"\n      onKeyDown={handleKeyDown}\n      className={cn(\n        \"relative inline-flex items-center rounded-full border border-white/10\",\n        SIZES[size],\n        disabled && \"pointer-events-none opacity-50\",\n        className\n      )}\n      style={{\n        // Machined groove: a shaded well the pill sits down into.\n        backgroundImage: \"linear-gradient(180deg, #171719 0%, #0f0f11 100%)\",\n        boxShadow:\n          \"inset 0 1px 2px rgba(0,0,0,0.6), inset 0 -1px 0 rgba(255,255,255,0.03)\",\n      }}\n      {...props}\n    >\n      {options.map((option, index) => {\n        const selected = option.value === activeValue;\n        const tabbable = index === tabStop;\n        return (\n          <button\n            key={option.value}\n            ref={(el) => {\n              optionRefs.current[index] = el;\n            }}\n            role=\"radio\"\n            type=\"button\"\n            id={`${baseId}-opt-${option.value}`}\n            aria-checked={selected}\n            aria-disabled={option.disabled || undefined}\n            tabIndex={tabbable ? 0 : -1}\n            // Whole-control disable must also block Tab + Space/Enter, not just\n            // pointer events, so the native disabled attribute covers both.\n            disabled={disabled || option.disabled}\n            onClick={() => !disabled && !option.disabled && select(option.value)}\n            className={cn(\n              \"relative z-10 rounded-full font-medium whitespace-nowrap\",\n              \"text-white/50 transition-colors duration-200\",\n              OPTION_SIZES[size],\n              \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950\",\n              // Per-option dimming only — the whole-control disable already\n              // fades the group, so don't double-dim every option.\n              option.disabled ? \"cursor-not-allowed opacity-40\" : \"hover:text-white/80\",\n              selected && \"text-white\"\n            )}\n            style={\n              { [\"--tw-ring-color\" as string]: accent } as React.CSSProperties\n            }\n          >\n            {selected && (\n              <motion.span\n                layoutId={`${baseId}-quorum-pill`}\n                aria-hidden\n                className=\"absolute inset-0 -z-10 rounded-full\"\n                style={{\n                  // Machined pill: lit from above, a bright top hairline, a soft\n                  // drop shadow so it rides ABOVE the groove, and a hairline rim.\n                  backgroundImage:\n                    \"linear-gradient(180deg, #3b3b3f 0%, #2a2a2d 55%, #232326 100%)\",\n                  boxShadow: [\n                    \"inset 0 1px 0 rgba(255,255,255,0.22)\",\n                    \"inset 0 -1px 0 rgba(0,0,0,0.45)\",\n                    \"0 1px 1px rgba(0,0,0,0.5)\",\n                    \"0 2px 6px -1px rgba(0,0,0,0.55)\",\n                    \"0 0 0 1px rgba(255,255,255,0.08)\",\n                  ].join(\", \"),\n                }}\n                transition={\n                  reducedMotion || paused\n                    ? { duration: 0 }\n                    : { type: \"spring\", stiffness: 480, damping: 30, mass: 0.9 }\n                }\n                onLayoutAnimationComplete={() => {\n                  if (!reducedMotion && !paused) setLandCount((c) => c + 1);\n                }}\n              >\n                {/* Settle bloom: a faint accent flush fired the instant the pill\n                    lands. Gated on landCount>0 so mount is calm; suppressed under\n                    reduced motion. */}\n                <AnimatePresence>\n                  {landCount > 0 && (\n                    <motion.span\n                      key={landCount}\n                      className=\"pointer-events-none absolute inset-0 rounded-full\"\n                      style={{\n                        backgroundColor: accent,\n                        boxShadow: `0 0 20px 1px ${accent}`,\n                      }}\n                      initial={{ opacity: 0.28 }}\n                      animate={{ opacity: 0 }}\n                      exit={{ opacity: 0 }}\n                      transition={{ duration: 0.4, ease: \"easeOut\" }}\n                    />\n                  )}\n                </AnimatePresence>\n              </motion.span>\n            )}\n            <span className=\"relative\">{option.label}</span>\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n"
    }
  ]
}