{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"segmented-control","type":"registry:component","title":"Segmented control","description":"The pill stretches toward your pick like an inchworm, recoloring each label as its edge crosses.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/segmented-control.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { animate, motion, useMotionTemplate, useMotionValue } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// The pill travels like an inchworm: the edge facing the move leaves first\n// and lands first, the trailing edge follows on a softer spring and settles\n// with a touch of give. Both springs keep their velocity, so a second click\n// mid-move bends the pill toward the new target instead of restarting it.\n// The trailing edge settles by ~360ms; the leading edge has already landed\n// at ~180ms, which is the moment the label turns and the eye reads \"done\".\nconst LEAD = { type: \"spring\", visualDuration: 0.18, bounce: 0 } as const;\nconst TRAIL = { type: \"spring\", visualDuration: 0.36, bounce: 0.15 } as const;\n// Arrow keys fire in quick runs: both edges move together, briefly.\nconst KEYS = { type: \"spring\", visualDuration: 0.15, bounce: 0 } as const;\n\nexport function SegmentedControl({\n  options,\n  value,\n  onChange,\n  label,\n  className,\n}: {\n  options: readonly string[];\n  value: string;\n  onChange: (value: string) => void;\n  label: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const listRef = useRef<HTMLDivElement>(null);\n  const overlayRef = useRef<HTMLDivElement>(null);\n  const tabRefs = useRef(new Map<string, HTMLButtonElement>());\n  // Clip insets as motion values: the pill is drawn by clip-path alone, and\n  // no frame of the move re-renders React.\n  const top = useMotionValue(0);\n  const right = useMotionValue(0);\n  const bottom = useMotionValue(0);\n  const left = useMotionValue(0);\n  const clipPath = useMotionTemplate`inset(${top}px ${right}px ${bottom}px ${left}px round 9999px)`;\n  // Starts \"place\" so the pill is set without sliding in on page load.\n  const mode = useRef<\"place\" | \"click\" | \"key\">(\"place\");\n\n  useLayoutEffect(() => {\n    const list = listRef.current;\n    const overlay = overlayRef.current;\n    const tab = tabRefs.current.get(value);\n    if (!list || !overlay || !tab) return;\n\n    const measure = (resize: boolean) => {\n      const nextLeft = tab.offsetLeft;\n      const nextRight = list.clientWidth - nextLeft - tab.offsetWidth;\n      top.jump(tab.offsetTop);\n      bottom.jump(list.clientHeight - tab.offsetTop - tab.offsetHeight);\n      const how = resize || reduceMotion ? \"place\" : mode.current;\n      if (how === \"place\") {\n        left.jump(nextLeft);\n        right.jump(nextRight);\n      } else if (how === \"key\") {\n        animate(left, nextLeft, KEYS);\n        animate(right, nextRight, KEYS);\n      } else {\n        // Moving right, the right edge leads; moving left, the left does.\n        const toRight = nextLeft > left.get();\n        animate(left, nextLeft, toRight ? TRAIL : LEAD);\n        animate(right, nextRight, toRight ? LEAD : TRAIL);\n      }\n      overlay.style.visibility = \"visible\";\n    };\n\n    measure(false);\n    let first = true;\n    const observer = new ResizeObserver(() => {\n      // The observer fires once on subscribe; that is not a resize.\n      if (first) first = false;\n      else measure(true);\n    });\n    observer.observe(list);\n    return () => observer.disconnect();\n  }, [value, reduceMotion, top, right, bottom, left]);\n\n  const select = (next: string, fromKeyboard: boolean) => {\n    mode.current = fromKeyboard ? \"key\" : \"click\";\n    onChange(next);\n  };\n\n  return (\n    <div\n      ref={listRef}\n      role=\"radiogroup\"\n      aria-label={label}\n      className={cn(\n        \"relative inline-flex rounded-full bg-muted p-1 shadow-raised has-focus-visible:outline-2 has-focus-visible:outline-solid has-focus-visible:outline-offset-2 has-focus-visible:outline-foreground\",\n        className,\n      )}\n      onKeyDown={(e) => {\n        const index = options.indexOf(value);\n        const target = {\n          ArrowRight: index + 1,\n          ArrowDown: index + 1,\n          ArrowLeft: index - 1,\n          ArrowUp: index - 1,\n          Home: 0,\n          End: options.length - 1,\n        }[e.key];\n        if (target === undefined) return;\n        e.preventDefault();\n        const next = options[(target + options.length) % options.length];\n        select(next, true);\n        tabRefs.current.get(next)?.focus();\n      }}\n    >\n      {options.map((option) => (\n        <button\n          key={option}\n          ref={(el) => {\n            if (el) tabRefs.current.set(option, el);\n            else tabRefs.current.delete(option);\n          }}\n          type=\"button\"\n          role=\"radio\"\n          aria-checked={option === value}\n          tabIndex={option === value ? 0 : -1}\n          onClick={() => select(option, false)}\n          className=\"flex h-8 items-center rounded-full px-4 text-sm font-medium text-muted-foreground transition-[color] duration-150 ease-out outline-hidden hover:text-foreground\"\n        >\n          {option}\n        </button>\n      ))}\n\n      {/* An inverted copy of the row, clipped to the selected option. Moving\n          the clip recolors each label exactly as the pill's edge crosses it. */}\n      <motion.div\n        ref={overlayRef}\n        aria-hidden\n        style={{ clipPath }}\n        className=\"pointer-events-none invisible absolute inset-0 flex rounded-full bg-foreground p-1 text-background\"\n      >\n        {options.map((option) => (\n          <span\n            key={option}\n            className=\"flex h-8 items-center px-4 text-sm font-medium\"\n          >\n            {option}\n          </span>\n        ))}\n      </motion.div>\n    </div>\n  );\n}\n\nconst RANGES = [\"Day\", \"Week\", \"Month\", \"Year\"] as const;\n\nexport default function SegmentedControlDemo() {\n  const [range, setRange] = useState<string>(\"Week\");\n\n  return (\n    <SegmentedControl\n      label=\"Range\"\n      options={RANGES}\n      value={range}\n      onChange={setRange}\n    />\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["navigation"],"docs":"From ui lab: https://lab.xevrion.dev/lab/segmented-control"}