{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "segmented-control",
  "type": "registry:ui",
  "title": "Segmented control",
  "description": "Switch between a small set of related views.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/segmented-control/segmented-control.tsx",
      "type": "registry:component",
      "target": "@components/xzero/segmented-control/segmented-control.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, type KeyboardEvent, type ReactNode } from \"react\";\nimport { LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./segmented-control.module.css\";\n\nexport interface Segment { value: string; label: string; /** Optional content after the label, such as a badge. */ accessory?: ReactNode; }\nexport interface SegmentedControlProps {\n  options: Segment[];\n  value: string;\n  onValueChange: (value: string) => void;\n  label?: string;\n  /** Called when the pointer or focus reaches an option, before it is chosen. Use it to start loading what that option shows. */\n  onOptionIntent?: (value: string) => void;\n  className?: string;\n}\n\nexport default function SegmentedControl({ options, value, onValueChange, label, onOptionIntent, className }: SegmentedControlProps) {\n  const id = useId();\n  const reduced = useReducedMotion();\n  const track = useRef<HTMLDivElement>(null);\n\n  // When the options are wider than the container, the track scrolls inside itself. Edges fade only on the side with more to see.\n  useLayoutEffect(() => {\n    const node = track.current;\n    if (!node) return;\n    const edges = () => {\n      const rest = node.scrollWidth - node.clientWidth - node.scrollLeft;\n      node.toggleAttribute(\"data-fade-start\", node.scrollLeft > 1);\n      node.toggleAttribute(\"data-fade-end\", rest > 1);\n    };\n    edges();\n    node.addEventListener(\"scroll\", edges, { passive: true });\n    const observer = typeof ResizeObserver === \"undefined\" ? null : new ResizeObserver(edges);\n    observer?.observe(node);\n    return () => { node.removeEventListener(\"scroll\", edges); observer?.disconnect(); };\n  }, [options.length]);\n\n  // The selected option is always scrolled fully into view, with a little room so it clears the fade.\n  const first = useRef(true);\n  useEffect(() => {\n    const node = track.current;\n    const button = node?.querySelector<HTMLElement>('[aria-pressed=\"true\"]');\n    if (!node || !button || node.scrollWidth <= node.clientWidth) { first.current = false; return; }\n    const room = 20, start = button.offsetLeft - room, end = button.offsetLeft + button.offsetWidth + room - node.clientWidth;\n    const left = node.scrollLeft > start ? start : node.scrollLeft < end ? end : node.scrollLeft;\n    if (left !== node.scrollLeft) node.scrollTo({ left: Math.max(0, left), behavior: first.current || reduced ? \"auto\" : \"smooth\" });\n    first.current = false;\n  }, [value, reduced]);\n\n  // Arrow keys, Home and End move the selection like a tab list; only the selected option is a tab stop.\n  const selectedIndex = Math.max(0, options.findIndex(option => option.value === value));\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {\n    const last = options.length - 1;\n    const target = event.key === \"ArrowRight\" || event.key === \"ArrowDown\" ? (selectedIndex === last ? 0 : selectedIndex + 1)\n      : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\" ? (selectedIndex === 0 ? last : selectedIndex - 1)\n        : event.key === \"Home\" ? 0 : event.key === \"End\" ? last : -1;\n    if (target < 0 || !options[target]) return;\n    event.preventDefault();\n    onValueChange(options[target].value);\n    track.current?.querySelector<HTMLElement>(`[data-value=\"${CSS.escape(options[target].value)}\"]`)?.focus({ preventScroll: true });\n  };\n\n  return <div className={`${styles.root} ${className ?? \"\"}`} role=\"group\" aria-label={label}>\n    <LayoutGroup id={id}><motion.div ref={track} layoutScroll className={styles.track}>\n      {options.map((option, index) => <button key={option.value} id={`${id}-${option.value}`} className={styles.button} type=\"button\" data-value={option.value} aria-pressed={value === option.value} tabIndex={index === selectedIndex ? 0 : -1} onClick={() => onValueChange(option.value)} onKeyDown={onKeyDown} onPointerEnter={onOptionIntent ? () => onOptionIntent(option.value) : undefined} onFocus={onOptionIntent ? () => onOptionIntent(option.value) : undefined}>\n        {value === option.value && <motion.span className={styles.selection} layoutId=\"selection\" layoutDependency={value} transition={reduced ? { duration: 0 } : motionTokens.spring.morph} aria-hidden=\"true\" />}\n        <span className={styles.label}>{option.label}{option.accessory}</span>\n      </button>)}\n    </motion.div></LayoutGroup>\n  </div>;\n}\n"
    },
    {
      "path": "registry/components/segmented-control/segmented-control.module.css",
      "type": "registry:component",
      "target": "@components/xzero/segmented-control/segmented-control.module.css",
      "content": "/* The frame can shrink below its content inside flex and grid parents; the track then scrolls inside it. */\n.root { display: inline-flex; min-width: 0; max-width: 100%; flex-shrink: 1; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface-muted); }\n/* The track owns the stacking context so the gliding highlight passes under every label, not over earlier ones. */\n.track { --fade-start: 0px; --fade-end: 0px; isolation: isolate; display: flex; min-width: 0; gap: 2px; overflow-x: auto; overscroll-behavior-x: contain; padding: 3px; border-radius: inherit; scrollbar-width: none; -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); mask-image: linear-gradient(to right, transparent, #000 var(--fade-start), #000 calc(100% - var(--fade-end)), transparent); }\n.track::-webkit-scrollbar { display: none; }\n.track[data-fade-start] { --fade-start: 20px; }\n.track[data-fade-end] { --fade-end: 20px; }\n/* Items never scale or change weight; only the highlight travels and the label color follows it. */\n.button { position: relative; flex: 0 0 auto; min-height: var(--control-height-sm); border: 0; border-radius: calc(var(--radius-control) - 4px); padding: 0 12px; background: transparent; color: var(--text-muted); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .button:hover { color: var(--foreground); } }\n.button:active, .button[aria-pressed=\"true\"] { color: var(--foreground); }\n.selection { position: absolute; z-index: -1; inset: 0; border: 1px solid var(--border); border-radius: inherit; background: var(--surface); box-shadow: var(--shadow-resting); }\n.label { position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 6px; }\n@media (max-width: 380px) { .button { padding: 0 10px; } }\n@media (prefers-reduced-motion: reduce) { .button { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/segmented-control",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "control",
      "choice"
    ],
    "docs": "https://ui.xzero.co.uk/components/segmented-control",
    "markdown": "https://ui.xzero.co.uk/components/segmented-control/markdown"
  }
}