{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"mode-switcher","type":"registry:component","title":"Mode switcher","description":"Fast, Thinking, Research: a segmented switch with a sliding pill and locked paid modes.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json"],"files":[{"path":"registry/mode-switcher.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { Lock } from \"lucide-react\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * MODE SWITCHER: Fast, Thinking, Research…\n *\n * A segmented radio group with a pill that is thrown to the chosen\n * mode, with a little give. The description underneath morphs\n * into the new mode's line, keeping the words they share. Locked\n * modes stay visible with a lock, so people see what an upgrade\n * unlocks; choosing one calls onLockedSelect instead. Arrow keys\n * move between modes, like any radio group.\n * ───────────────────────────────────────────────────────── */\n\nexport interface Mode {\n  value: string;\n  label: string;\n  /** One line shown under the switcher for the chosen mode. */\n  description?: string;\n  icon?: React.ReactNode;\n  /** Visible but not selectable, e.g. a paid mode. */\n  locked?: boolean;\n}\n\nexport interface ModeSwitcherProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\" | \"defaultValue\"> {\n  modes: Mode[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Called when someone picks a locked mode, e.g. to open pricing. */\n  onLockedSelect?: (mode: Mode) => void;\n  /** Show the chosen mode's description underneath. */\n  showDescription?: boolean;\n  label?: string;\n}\n\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nexport function ModeSwitcher({\n  modes,\n  value,\n  defaultValue,\n  onValueChange,\n  onLockedSelect,\n  showDescription = false,\n  label = \"Mode\",\n  className = \"\",\n  ...props\n}: ModeSwitcherProps) {\n  const reduced = useReducedMotion();\n  const [own, setOwn] = React.useState(defaultValue ?? modes.find((m) => !m.locked)?.value);\n  const current = value ?? own;\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const [pill, setPill] = React.useState<{ left: number; width: number; ready: boolean } | null>(null);\n  const index = modes.findIndex((m) => m.value === current);\n  const active = modes[index];\n\n  // Measure the chosen button so the pill can slide to it; re-measure when the container resizes.\n  React.useLayoutEffect(() => {\n    const el = refs.current[index];\n    if (!el) return;\n    // The first placement doesn't animate; every move after that does.\n    const measure = () =>\n      setPill((p) => (p && p.left === el.offsetLeft && p.width === el.offsetWidth ? p : { left: el.offsetLeft, width: el.offsetWidth, ready: p !== null }));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [index]);\n\n  const choose = (mode: Mode) => {\n    if (mode.locked) {\n      onLockedSelect?.(mode);\n      return;\n    }\n    if (value === undefined) setOwn(mode.value);\n    onValueChange?.(mode.value);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (![\"ArrowRight\", \"ArrowLeft\", \"ArrowDown\", \"ArrowUp\"].includes(e.key)) return;\n    e.preventDefault();\n    const dir = e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? 1 : -1;\n    const open = modes.map((m, i) => ({ m, i })).filter(({ m }) => !m.locked);\n    const at = open.findIndex(({ i }) => i === index);\n    const next = open[(at + dir + open.length) % open.length];\n    if (!next) return;\n    choose(next.m);\n    refs.current[next.i]?.focus();\n  };\n\n  return (\n    <div className={`inline-flex flex-col gap-1.5 ${className}`} {...props}>\n      <div role=\"radiogroup\" aria-label={label} onKeyDown={onKeyDown} className=\"relative inline-flex w-fit items-center rounded-full bg-muted p-0.5\">\n        {pill && (\n          <span\n            aria-hidden=\"true\"\n            className=\"absolute inset-y-0.5 left-0 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]\"\n            style={{\n              width: pill.width,\n              transform: `translateX(${pill.left}px)`,\n              transition: pill.ready && !reduced ? `transform 460ms ${THROW}, width 380ms ${EASE}` : \"none\",\n            }}\n          />\n        )}\n        {modes.map((mode, i) => {\n          const selected = mode.value === current;\n          return (\n            <button\n              key={mode.value}\n              ref={(el) => {\n                refs.current[i] = el;\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={selected}\n              aria-disabled={mode.locked || undefined}\n              tabIndex={selected ? 0 : -1}\n              title={mode.locked ? `${mode.label} isn’t available on your plan` : mode.description}\n              onClick={() => choose(mode)}\n              className={`relative z-10 flex h-7 items-center gap-1.5 rounded-full px-3 text-[12.5px] font-medium transition-colors ${FOCUS} ${\n                selected ? \"text-foreground\" : mode.locked ? \"text-muted-foreground/70\" : \"text-muted-foreground hover:text-foreground\"\n              }`}\n            >\n              {mode.icon && (\n                <span aria-hidden=\"true\" className=\"flex [&>svg]:size-3.5\">\n                  {mode.icon}\n                </span>\n              )}\n              {mode.label}\n              {mode.locked && <Lock aria-hidden=\"true\" className=\"size-3\" />}\n            </button>\n          );\n        })}\n      </div>\n      {showDescription && (\n        // One line that morphs from mode to mode, rather than being swapped.\n        <p className=\"min-h-[18px] px-1 text-[12px] leading-[18px] text-muted-foreground\">\n          <TextMorph animateWidth={false}>{active?.description ?? \"\"}</TextMorph>\n        </p>\n      )}\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/mode-switcher"}