{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"segmented-control","type":"registry:component","title":"Segmented control","description":"One of a few options side by side: the thumb is thrown to the new segment while its width eases to the new label.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/segmented-control.tsx","type":"registry:component","content":"// Hairline UI · segmented-control · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, EASE_THROW, INTERACTION_FOCUS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * SEGMENTED CONTROL: one of a few, side by side\n *\n *   pick     the thumb is thrown to the new segment with a slight\n *            overshoot while its width eases to the new label\n *   press    the thumb gives a little under the finger\n *   keys     a radiogroup: arrows move and select, Home/End jump;\n *            one tab stop for the whole control\n * ───────────────────────────────────────────────────────── */\n\nexport interface SegmentOption {\n  value: string;\n  label: React.ReactNode;\n  icon?: React.ReactNode;\n  /** Required when `label` isn't text (icon-only segments). */\n  ariaLabel?: string;\n  disabled?: boolean;\n}\n\nexport interface SegmentedControlProps {\n  options: SegmentOption[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Accessible name of the group. */\n  label: string;\n  /** 28px or 36px tall. */\n  size?: \"sm\" | \"md\";\n  /** Stretch to the parent's width with equal segments. */\n  block?: boolean;\n  className?: string;\n}\n\ntype Mark = { x: number; w: number };\n\nexport function SegmentedControl({ options, value, defaultValue, onValueChange, label, size = \"md\", block, className = \"\" }: SegmentedControlProps) {\n  const reduced = useInteractionReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const [own, setOwn] = React.useState(defaultValue ?? options[0]?.value);\n  const current = value !== undefined ? value : own;\n  const [mark, setMark] = React.useState<Mark | null>(null);\n  const [ready, setReady] = React.useState(false);\n  const [pressing, setPressing] = React.useState(false);\n\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const measure = () => {\n      const el = current ? root.querySelector<HTMLElement>(`[data-segment=\"${CSS.escape(current)}\"]`) : null;\n      if (!el) return setMark(null);\n      const next = { x: el.offsetLeft, w: el.offsetWidth };\n      setMark((p) => (p && p.x === next.x && p.w === next.w ? p : next));\n    };\n    measure();\n    const ro = new ResizeObserver(measure);\n    ro.observe(root);\n    root.querySelectorAll(\"[data-segment]\").forEach((el) => ro.observe(el));\n    return () => ro.disconnect();\n  }, [current, options]);\n\n  React.useEffect(() => {\n    if (!mark || ready) return;\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n  }, [mark, ready]);\n\n  const pick = (v: string) => {\n    if (value === undefined) setOwn(v);\n    onValueChange?.(v);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const enabled = options.filter((o) => !o.disabled);\n    const index = enabled.findIndex((o) => o.value === current);\n    let next = -1;\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") next = (index + 1) % enabled.length;\n    else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") next = (index - 1 + enabled.length) % enabled.length;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = enabled.length - 1;\n    if (next < 0) return;\n    e.preventDefault();\n    const v = enabled[next].value;\n    pick(v);\n    rootRef.current?.querySelector<HTMLElement>(`[data-segment=\"${CSS.escape(v)}\"]`)?.focus();\n  };\n\n  const sm = size === \"sm\";\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"radiogroup\"\n      aria-label={label}\n      onKeyDown={onKeyDown}\n      className={`relative isolate select-none items-center rounded-full bg-muted p-0.5 shadow-[inset_0_0_0_1px_var(--border)] ${block ? \"flex w-full\" : \"inline-flex\"} ${className}`}\n    >\n      <span\n        aria-hidden=\"true\"\n        className={`pointer-events-none absolute left-0 top-0.5 bottom-0.5 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]`}\n        style={{\n          transform: `translate3d(${mark?.x ?? 0}px, 0, 0) scale(${pressing && !reduced ? 0.97 : 1})`,\n          width: mark?.w ?? 0,\n          opacity: mark ? 1 : 0,\n          transition: reduced || !ready ? \"none\" : `transform 440ms ${EASE_THROW}, width 400ms ${EASE_OUT}, opacity 200ms ${EASE_OUT}`,\n        }}\n      />\n      {options.map((option) => {\n        const on = option.value === current;\n        return (\n          <button\n            key={option.value}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={on}\n            aria-label={option.ariaLabel}\n            data-segment={option.value}\n            disabled={option.disabled}\n            tabIndex={on ? 0 : -1}\n            onClick={() => pick(option.value)}\n            onPointerDown={() => on && setPressing(true)}\n            onPointerUp={() => setPressing(false)}\n            onPointerLeave={() => setPressing(false)}\n            className={`relative z-10 inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-full font-medium cursor-pointer transition-colors duration-200 disabled:cursor-default disabled:opacity-40 [&_svg]:size-3.5 ${\n              sm ? \"h-6 px-2.5 text-[12px]\" : \"h-8 px-3.5 text-[13px]\"\n            } ${block ? \"flex-1\" : \"\"} ${on ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\"} ${INTERACTION_FOCUS}`}\n          >\n            {option.icon}\n            {option.label}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nexport default SegmentedControl;\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/segmented-control. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}