{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"segmented-control","type":"registry:ui","title":"Segmented control","description":"Segmented control, part of the Blocks family.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/segmented-control.tsx","type":"registry:ui","target":"components/ui/segmented-control.tsx","content":"\"use client\";\n\nimport {\n  useLayoutEffect,\n  useRef,\n  useState,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./shared/segmented-plate.css\";\n\n/** With an icon, the option shows only the icon and `label` becomes its accessible name and tooltip. */\nexport type SegmentedOption = { value: string; label: string; icon?: ReactNode };\n\nexport function SegmentedControl({\n  label,\n  value,\n  onValueChange,\n  options,\n  className,\n}: {\n  label: string;\n  value: string;\n  onValueChange: (value: string) => void;\n  options: readonly SegmentedOption[];\n  className?: string;\n}) {\n  const group = useRef<HTMLDivElement>(null);\n  const [initialValue] = useState(value);\n  const [indicator, setIndicator] = useState<{\n    left: number;\n    top: number;\n    width: number;\n    height: number;\n  } | null>(null);\n\n  useLayoutEffect(() => {\n    const element = group.current;\n    if (!element) return;\n    const measure = () => {\n      const active = element.querySelector<HTMLElement>('[aria-checked=\"true\"]');\n      setIndicator(\n        active\n          ? {\n              left: active.offsetLeft,\n              top: active.offsetTop,\n              width: active.offsetWidth,\n              height: active.offsetHeight,\n            }\n          : null,\n      );\n    };\n    measure();\n    const resize = new ResizeObserver(measure);\n    resize.observe(element);\n    element\n      .querySelectorAll('[role=\"radio\"]')\n      .forEach((item) => resize.observe(item));\n    return () => resize.disconnect();\n  }, [value, options]);\n\n  function move(event: KeyboardEvent<HTMLDivElement>) {\n    const index = options.findIndex((item) => item.value === value);\n    const next =\n      event.key === \"ArrowRight\" || event.key === \"ArrowDown\"\n        ? (index + 1) % options.length\n        : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\"\n          ? (index - 1 + options.length) % options.length\n          : -1;\n    if (next < 0) return;\n    event.preventDefault();\n    onValueChange(options[next].value);\n    group.current\n      ?.querySelectorAll<HTMLButtonElement>('[role=\"radio\"]')\n      [next]?.focus();\n  }\n\n  return (\n    <div\n      ref={group}\n      role=\"radiogroup\"\n      aria-label={label}\n      data-slot=\"segmented\"\n      onKeyDown={move}\n      className={cn(\n        // Too many options for a narrow screen scroll sideways instead of spilling out.\n        \"relative inline-flex max-w-full overflow-x-auto rounded-touch bg-sunken p-[3px] shadow-sunken [scrollbar-width:none]\",\n        className,\n      )}\n    >\n      {indicator && (\n        <span\n          aria-hidden=\"true\"\n          data-slot=\"segmented-indicator\"\n          style={{\n            width: indicator.width,\n            height: indicator.height,\n            transform: `translate(${indicator.left}px, ${indicator.top}px)`,\n          }}\n        >\n          <span\n            key={value}\n            data-slot=\"segmented-plate\"\n            data-moved={value !== initialValue}\n          />\n        </span>\n      )}\n      {options.map((option) => {\n        const selected = option.value === value;\n        return (\n          <button\n            key={option.value}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={selected}\n            tabIndex={selected ? 0 : -1}\n            aria-label={option.icon ? option.label : undefined}\n            title={option.icon ? option.label : undefined}\n            onClick={() => onValueChange(option.value)}\n            className=\"relative z-10 inline-flex shrink-0 h-[calc(var(--control-height)-6px)] min-w-10 items-center justify-center rounded-[calc(var(--radius-touch)-3px)] px-3 text-[13px] font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-ring aria-checked:text-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0\"\n          >\n            {option.icon ?? option.label}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n"},{"path":"ui/shared/segmented-plate.css","type":"registry:file","target":"components/ui/shared/segmented-plate.css","content":"/* Shared by billing-toggle.tsx, segmented-control.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Segmented control: one raised plate travels to the choice on the settle\n     curve and stretches 7% toward it mid-flight, so selection reads as one\n     object moving. Labels and the value change at once. */\n  [data-slot=\"segmented-indicator\"] {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 0;\n    pointer-events: none;\n    transition:\n      transform 320ms var(--ease-settle),\n      width 320ms var(--ease-settle);\n  }\n  [data-slot=\"segmented-plate\"] {\n    display: block;\n    width: 100%;\n    height: 100%;\n    /* Concentric with the track: its radius less the 3px inset. */\n    border-radius: calc(var(--radius-touch) - 3px);\n    background: var(--accent);\n    box-shadow: var(--shadow-plate);\n  }\n  [data-slot=\"segmented-plate\"][data-moved=\"true\"] {\n    animation: caliper-plate-stretch 320ms var(--ease-out);\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"segmented-indicator\"],\n    [data-slot=\"segmented-plate\"] {\n      transition: none;\n    }\n    [data-slot=\"segmented-plate\"][data-moved=\"true\"] {\n      animation: none;\n    }\n  }\n}\n"}],"categories":["data"],"meta":{"tier":"free","license":"MIT","family":"blocks","docs":"/docs/components/segmented-control"}}