{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"segmented-control","type":"registry:component","title":"Segmented Control","description":"An iOS-style segmented control with a draggable thumb: grab it and drag across segments, it stretches with its own velocity, grows under your finger and settles with a touch of overshoot. Icons, sizes, two thumb styles, light and dark. No dependencies.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/segmented-control.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type { CSSProperties, KeyboardEvent, PointerEvent, ReactNode } from \"react\";\n\nconst DEFAULT_OPTIONS = [\"Day\", \"Week\", \"Month\"];\nconst SAFE_COLOR = /^(#[0-9a-f]{3,8}|[a-z]{3,20})$/i;\n\n// Springs, [stiffness, damping]. SETTLE has ~5% overshoot, FOLLOW trails a finger without wobble.\nconst SETTLE: [number, number] = [420, 28];\nconst FOLLOW: [number, number] = [900, 52];\nconst PRESS: [number, number] = [600, 34];\n// How far the thumb may poke past the inner edge of the track (the track has 4px padding).\nconst EDGE = 3;\n\nconst SIZES = {\n  sm: { h: \"30px\", fs: \"13px\", px: \"12px\" },\n  md: { h: \"36px\", fs: \"14px\", px: \"16px\" },\n  lg: { h: \"44px\", fs: \"16px\", px: \"20px\" },\n} as const;\n\nconst TRACK = {\n  light: { bg: \"#f5f5f5\", border: \"#e5e5e5\", inset: \"inset 0 1px 2px rgba(0,0,0,0.06)\", text: \"#737373\", hover: \"#262626\" },\n  dark: { bg: \"rgba(255,255,255,0.06)\", border: \"rgba(255,255,255,0.1)\", inset: \"inset 0 1px 3px rgba(0,0,0,0.5)\", text: \"rgba(255,255,255,0.55)\", hover: \"#ffffff\" },\n} as const;\n\ntype Engine = {\n  go: (i: number, snap: boolean) => void;\n  down: (px: number) => boolean;\n  move: (px: number) => boolean;\n  up: (commit: boolean) => number;\n};\n\n/**\n * An iOS-style segmented control. One thumb rides the track on a spring with a touch of overshoot,\n * stretches and flattens with its own velocity, grows slightly under your finger, and can be\n * dragged: release it and it settles on the nearest segment (a flick carries it further).\n * Labels flip colour exactly where the thumb passes. Radiogroup semantics, roving tabindex,\n * arrow keys / Home / End. Hand-written springs, no dependencies.\n */\nexport function SegmentedControl({\n  options = DEFAULT_OPTIONS,\n  defaultIndex = 0,\n  onChange,\n  icons,\n  size = \"md\",\n  shape = \"pill\",\n  variant = \"accent\",\n  accent = \"#ef4444\",\n  accentText = \"#ffffff\",\n  tone = \"light\",\n  disabled = false,\n  className = \"\",\n  ariaLabel = \"Segmented control\",\n}: {\n  options?: string[];\n  defaultIndex?: number;\n  onChange?: (index: number) => void;\n  icons?: ReactNode[];\n  size?: \"sm\" | \"md\" | \"lg\";\n  shape?: \"pill\" | \"rounded\";\n  variant?: \"accent\" | \"neutral\";\n  accent?: string;\n  accentText?: string;\n  tone?: \"light\" | \"dark\";\n  disabled?: boolean;\n  className?: string;\n  ariaLabel?: string;\n}) {\n  const count = options.length;\n  const [picked, setPicked] = useState(defaultIndex);\n  const active = Math.max(0, Math.min(picked, count - 1));\n  const dark = tone === \"dark\";\n  const t = TRACK[dark ? \"dark\" : \"light\"];\n  const dims = SIZES[size === \"sm\" || size === \"lg\" ? size : \"md\"];\n  const pill = shape === \"pill\";\n  const accentColor = SAFE_COLOR.test(accent) ? accent : \"#ef4444\";\n  const accentInk = SAFE_COLOR.test(accentText) ? accentText : \"#ffffff\";\n  const neutral = variant === \"neutral\";\n  const thumbInk = neutral ? (dark ? \"#0a0a0a\" : \"#171717\") : accentInk;\n  const thumbBg = neutral ? (dark ? \"#f4f4f5\" : \"#ffffff\") : accentColor;\n  const thumbShadow = neutral\n    ? dark\n      ? \"0 1px 2px rgba(0,0,0,0.5), 0 8px 18px -6px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.9)\"\n      : \"0 0 0 0.5px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.12), 0 6px 14px -4px rgba(0,0,0,0.16)\"\n    : \"0 6px 16px -6px color-mix(in srgb, \" + accentColor + \" 70%, transparent), 0 1px 2px color-mix(in srgb, \" + accentColor + \" 40%, transparent), inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -1px 0 rgba(0,0,0,0.12)\";\n  const thumbGloss = neutral ? \"none\" : \"linear-gradient(180deg, rgba(255,255,255,0.24), rgba(255,255,255,0) 60%)\";\n  const round = pill ? \"rounded-full\" : \"rounded-lg\";\n  const roundFocus = pill ? \"focus-visible:!rounded-full\" : \"focus-visible:!rounded-lg\";\n\n  const innerRef = useRef<HTMLDivElement>(null);\n  const thumbRef = useRef<HTMLDivElement>(null);\n  const overRef = useRef<HTMLDivElement>(null);\n  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const engine = useRef<Engine | null>(null);\n  const activeRef = useRef(active);\n  const onChangeRef = useRef(onChange);\n  const draggedRef = useRef(false);\n  useEffect(() => {\n    activeRef.current = active;\n    onChangeRef.current = onChange;\n  });\n\n  // The engine: one rAF loop that sleeps at rest. Everything it touches is written straight to the DOM.\n  useEffect(() => {\n    const inner = innerRef.current;\n    const thumb = thumbRef.current;\n    const over = overRef.current;\n    if (!inner || !thumb || !over || count === 0) return;\n    let centers: number[] = [];\n    let segW = 0;\n    let top = 0;\n    let h = 0;\n    let boxW = 0;\n    let boxH = 0;\n    let x = 0;\n    let v = 0;\n    let tx = 0;\n    let k = SETTLE[0];\n    let c = SETTLE[1];\n    let p = 0;\n    let pv = 0;\n    let pt = 0;\n    let armed = false;\n    let dragging = false;\n    let startPx = 0;\n    let grab = 0;\n    let near = -1;\n    let shown = false;\n    let raf = 0;\n    let last = 0;\n    const reduced = () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    const measure = () => {\n      const list = btnRefs.current.slice(0, count);\n      if (list.length < count || list.some((b) => !b)) return false;\n      centers = list.map((b) => (b ? b.offsetLeft + b.offsetWidth / 2 : 0));\n      const first = list[0];\n      if (!first) return false;\n      segW = first.offsetWidth;\n      top = first.offsetTop;\n      h = first.offsetHeight;\n      boxW = inner.offsetWidth;\n      boxH = inner.offsetHeight;\n      return segW > 0;\n    };\n    const nearest = (px: number) => {\n      let best = 0;\n      let bd = Infinity;\n      centers.forEach((cx, i) => {\n        const d = Math.abs(cx - px);\n        if (d < bd) {\n          bd = d;\n          best = i;\n        }\n      });\n      return best;\n    };\n    const write = () => {\n      const s = reduced() ? 0 : Math.min(1, Math.abs(v) / 1800);\n      const grow = 1 + 0.05 * p;\n      const w = segW * (1 + 0.16 * s) * grow;\n      const ht = h * (1 - 0.09 * s) * grow;\n      const left = Math.max(-EDGE, Math.min(boxW + EDGE - w, x - w / 2));\n      const y = top + (h - ht) / 2;\n      const r = pill ? ht / 2 : Math.min(8, ht / 2);\n      thumb.style.transform = \"translate3d(\" + left.toFixed(2) + \"px,\" + y.toFixed(2) + \"px,0)\";\n      thumb.style.width = w.toFixed(2) + \"px\";\n      thumb.style.height = ht.toFixed(2) + \"px\";\n      thumb.style.borderRadius = r.toFixed(2) + \"px\";\n      over.style.clipPath =\n        \"inset(\" + y.toFixed(2) + \"px \" + (boxW - left - w).toFixed(2) + \"px \" + (boxH - y - ht).toFixed(2) + \"px \" + left.toFixed(2) + \"px round \" + r.toFixed(2) + \"px)\";\n      if (!shown) {\n        shown = true;\n        thumb.style.visibility = \"visible\";\n      }\n    };\n    const tick = (now: number) => {\n      const dt = Math.min(0.05, (now - last) / 1000);\n      last = now;\n      const n = Math.max(1, Math.ceil(dt / 0.004));\n      const sub = dt / n;\n      for (let s = 0; s < n; s++) {\n        v += (-k * (x - tx) - c * v) * sub;\n        x += v * sub;\n        pv += (-PRESS[0] * (p - pt) - PRESS[1] * pv) * sub;\n        p += pv * sub;\n      }\n      const rest = !dragging && Math.abs(x - tx) < 0.02 && Math.abs(v) < 0.05 && Math.abs(p - pt) < 0.001 && Math.abs(pv) < 0.01;\n      if (rest) {\n        x = tx;\n        v = 0;\n        p = pt;\n        pv = 0;\n        write();\n        raf = 0;\n        return;\n      }\n      write();\n      raf = requestAnimationFrame(tick);\n    };\n    const wake = () => {\n      if (!raf) {\n        last = performance.now();\n        raf = requestAnimationFrame(tick);\n      }\n    };\n    const settleTo = (i: number) => {\n      tx = centers[i] ?? tx;\n      k = SETTLE[0];\n      c = SETTLE[1];\n      wake();\n    };\n\n    engine.current = {\n      go(i, snap) {\n        if (!measure() || dragging) return;\n        tx = centers[i] ?? tx;\n        if (snap || !shown || reduced()) {\n          x = tx;\n          v = 0;\n          write();\n          return;\n        }\n        settleTo(i);\n      },\n      down(px) {\n        if (!measure() || Math.abs(px - x) > segW / 2) return false;\n        armed = true;\n        dragging = false;\n        startPx = px;\n        grab = px - x;\n        if (!reduced()) {\n          pt = 1;\n          wake();\n        }\n        return true;\n      },\n      move(px) {\n        if (!armed) return false;\n        let started = false;\n        if (!dragging && Math.abs(px - startPx) > 3) {\n          dragging = true;\n          started = true;\n          near = nearest(x);\n          k = FOLLOW[0];\n          c = FOLLOW[1];\n        }\n        if (dragging) {\n          const lo = centers[0] ?? 0;\n          const hi = centers[count - 1] ?? 0;\n          let target = px - grab;\n          if (target < lo) target = lo - (lo - target) * 0.25;\n          else if (target > hi) target = hi + (target - hi) * 0.25;\n          tx = target;\n          if (reduced()) {\n            x = tx;\n            write();\n          } else wake();\n          const nn = nearest(tx);\n          if (nn !== near) {\n            near = nn;\n            if (typeof navigator.vibrate === \"function\") navigator.vibrate(4);\n          }\n        }\n        return started;\n      },\n      up(commit) {\n        if (!armed) return -1;\n        armed = false;\n        pt = 0;\n        const was = dragging;\n        dragging = false;\n        if (!was) {\n          wake();\n          return -1;\n        }\n        const idx = commit ? nearest(x + v * 0.1) : activeRef.current;\n        settleTo(idx);\n        return idx;\n      },\n    };\n    engine.current.go(activeRef.current, true);\n    const ro = new ResizeObserver(() => engine.current?.go(activeRef.current, true));\n    ro.observe(inner);\n    for (const b of btnRefs.current) if (b) ro.observe(b);\n    const fonts = document.fonts;\n    if (fonts) void fonts.ready.then(() => engine.current?.go(activeRef.current, true));\n    return () => {\n      ro.disconnect();\n      if (raf) cancelAnimationFrame(raf);\n      engine.current = null;\n    };\n  }, [count, pill]);\n\n  useEffect(() => {\n    engine.current?.go(active, false);\n  }, [active]);\n\n  const choose = (i: number) => {\n    if (disabled || i === active) return;\n    setPicked(i);\n    onChangeRef.current?.(i);\n  };\n\n  const layoutX = (e: PointerEvent<HTMLDivElement>) => {\n    const el = e.currentTarget;\n    const r = el.getBoundingClientRect();\n    return (e.clientX - r.left) * (el.offsetWidth / (r.width || 1));\n  };\n  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {\n    if (disabled || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n    draggedRef.current = false;\n    engine.current?.down(layoutX(e));\n  };\n  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {\n    const started = engine.current?.move(layoutX(e));\n    if (started) {\n      draggedRef.current = true;\n      try {\n        e.currentTarget.setPointerCapture(e.pointerId);\n      } catch {\n        /* the pointer may already be gone */\n      }\n    }\n  };\n  const finish = (e: PointerEvent<HTMLDivElement>, commit: boolean) => {\n    const idx = engine.current?.up(commit) ?? -1;\n    try {\n      e.currentTarget.releasePointerCapture(e.pointerId);\n    } catch {\n      /* not captured */\n    }\n    if (idx >= 0) choose(idx);\n    // The click that follows a drag must not re-select the segment under the pointer.\n    setTimeout(() => {\n      draggedRef.current = false;\n    }, 0);\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    const key = e.key;\n    let next = -1;\n    if (key === \"ArrowRight\" || key === \"ArrowDown\") next = (active + 1) % count;\n    else if (key === \"ArrowLeft\" || key === \"ArrowUp\") next = (active - 1 + count) % count;\n    else if (key === \"Home\") next = 0;\n    else if (key === \"End\") next = count - 1;\n    if (next < 0) return;\n    e.preventDefault();\n    choose(next);\n    btnRefs.current[next]?.focus();\n  };\n\n  const labelBox =\n    \"flex h-[var(--scx-h)] items-center justify-center gap-[0.5em] whitespace-nowrap px-[var(--scx-px)] text-[length:var(--scx-fs)] font-semibold leading-none\";\n  const iconBox = \"inline-flex h-[1.15em] w-[1.15em] shrink-0 items-center justify-center [&>svg]:h-full [&>svg]:w-full\";\n\n  const rootStyle = {\n    \"--scx-h\": \"var(--sc-h, \" + dims.h + \")\",\n    \"--scx-fs\": \"var(--sc-fs, \" + dims.fs + \")\",\n    \"--scx-px\": \"var(--sc-px, \" + dims.px + \")\",\n    \"--scx-text\": t.text,\n    \"--scx-hover\": t.hover,\n    background: t.bg,\n    borderColor: t.border,\n    boxShadow: t.inset,\n    opacity: disabled ? 0.5 : 1,\n    WebkitTapHighlightColor: \"transparent\",\n  } as CSSProperties;\n\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={ariaLabel}\n      aria-disabled={disabled || undefined}\n      onKeyDown={onKeyDown}\n      className={\"inline-flex select-none border p-1 \" + (pill ? \"rounded-full\" : \"rounded-xl\") + \" \" + className}\n      style={rootStyle}\n    >\n      <div\n        ref={innerRef}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={(e) => finish(e, true)}\n        onPointerCancel={(e) => finish(e, false)}\n        className=\"relative grid min-w-0 flex-1 auto-cols-fr grid-flow-col\"\n        style={{ touchAction: \"pan-y\" }}\n      >\n        {options.map((option, i) => {\n          const on = i === active;\n          return (\n            <button\n              key={option + \"\\u0001\" + i}\n              ref={(el) => {\n                btnRefs.current[i] = el;\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={on}\n              disabled={disabled}\n              tabIndex={on ? 0 : -1}\n              onClick={() => {\n                if (!draggedRef.current) choose(i);\n              }}\n              className={\n                \"group min-w-0 cursor-pointer border-0 bg-transparent p-0 text-[color:var(--scx-text)] transition-colors duration-200 hover:text-[color:var(--scx-hover)] focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-sky-500 disabled:cursor-not-allowed motion-reduce:transition-none \" +\n                round +\n                \" \" +\n                roundFocus\n              }\n            >\n              <span className={labelBox + \" transition-transform duration-150 ease-out group-active:scale-[0.96] motion-reduce:transition-none\"}>\n                {icons?.[i] ? <span className={iconBox}>{icons[i]}</span> : null}\n                {option}\n              </span>\n            </button>\n          );\n        })}\n        <div\n          ref={thumbRef}\n          aria-hidden\n          className=\"pointer-events-none absolute left-0 top-0\"\n          style={{ visibility: \"hidden\", willChange: \"transform, width, height\", backgroundColor: thumbBg, backgroundImage: thumbGloss, boxShadow: thumbShadow }}\n        />\n        <div\n          ref={overRef}\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 grid auto-cols-fr grid-flow-col\"\n          style={{ color: thumbInk, clipPath: \"inset(0 100% 0 0)\" }}\n        >\n          {options.map((option, i) => (\n            <span key={option + \"\\u0001\" + i} className={labelBox}>\n              {icons?.[i] ? <span className={iconBox}>{icons[i]}</span> : null}\n              {option}\n            </span>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n","type":"registry:component","target":"components/viberdy/segmented-control.tsx"}],"meta":{"tier":"free","tags":["segmented","control","switch","toggle","drag","spring","ios","radiogroup"],"href":"https://viberdy.dev/library/segmented-control","aiPrompt":"Create a React + Tailwind \"use client\" component named SegmentedControl: an iOS-style segmented control with a draggable thumb. React only, no animation library; write the springs by hand.\n\nProps (all optional): options (string[], default [\"Day\", \"Week\", \"Month\"]), defaultIndex (default 0), onChange(index: number), icons (ReactNode[], one per option, rendered before the label at 1.15em), size (\"sm\" | \"md\" | \"lg\", default \"md\" = 30/36/44px segment height, 13/14/16px type, 12/16/20px side padding, exposed as CSS variables --sc-h, --sc-fs, --sc-px that a className can override), shape (\"pill\" | \"rounded\", default \"pill\"; rounded = 12px track, 8px thumb), variant (\"accent\" | \"neutral\", default \"accent\"), accent (default #ef4444) and accentText (default #ffffff), both validated against /^(#[0-9a-f]{3,8}|[a-z]{3,20})$/i before use, tone (\"light\" | \"dark\", default \"light\"), disabled, className, ariaLabel (default \"Segmented control\").\n\nStructure: a track (inline-flex, 4px padding, 1px border, inset shadow) containing a relative grid with equal-width columns (grid-flow-col auto-cols-fr). Inside it: the segment buttons, then ONE absolutely positioned thumb (pointer-events none, opaque, drawn above the buttons), then an aria-hidden overlay of the same labels in the thumb ink colour, clipped with clip-path: inset(...) to exactly the thumb's rectangle every frame. The overlay is how the label colour flips where the thumb passes without stretching the text.\n\nColours (the component paints its own surfaces): light track #f5f5f5, border #e5e5e5, labels #737373, hover #262626; dark track rgba(255,255,255,.06), border rgba(255,255,255,.1), labels rgba(255,255,255,.55), hover #fff. Accent thumb = the accent colour with a faint top gloss gradient and a soft coloured shadow; neutral thumb = white on light, #f4f4f5 on dark, ink #171717 / #0a0a0a, soft layered shadow. Label type weight 600, inherit the font family.\n\nMotion: one requestAnimationFrame loop that sleeps when everything is at rest, integrating springs in sub-steps of at most 4ms with dt capped at 50ms, writing straight to the DOM (transform translate3d, width, height, border-radius, clip-path). Thumb centre x spring while settling: stiffness 420, damping 28 (about 5% overshoot). While dragging: stiffness 900, damping 52. Press scale spring: stiffness 600, damping 34 toward 1 while the thumb is held (thumb grows 5%). Squish from velocity: s = min(1, |v| / 1800); width x (1 + 0.16 s), height x (1 - 0.09 s). Clamp the thumb so it never pokes more than 3px past the inner edge of the track. Hide the thumb until the first measurement. Re-measure with a ResizeObserver on the grid and every button and after document.fonts.ready; snap, do not animate, on re-measure.\n\nInteraction: pointerdown on the thumb (within half a segment of its centre) arms a drag; after 3px of movement it starts dragging, captures the pointer, and the thumb follows (pointer x minus the grab offset) with rubber-banding at 25% beyond the first and last centre. Map pointer coordinates in layout pixels: (clientX - rect.left) * (offsetWidth / rect.width). On release choose the segment nearest to (x + velocity * 0.1), so a flick carries one segment further, spring there and call onChange. Fire navigator.vibrate(4) when the nearest segment changes during a drag if available. Set touch-action: pan-y on the grid so vertical page scroll still works. After a drag, swallow the click that follows. Clicking a segment selects it and the thumb springs over. Keyboard: radiogroup with roving tabindex (selected segment is the only tab stop); ArrowLeft/Up and ArrowRight/Down move and select with wrap-around, Home/End jump; focus follows selection.\n\nStates: hover brightens inactive labels (200ms colour transition), pressing a segment scales its label to 0.96, focus-visible ring (sky-500, 2px, offset 2, use important utilities because the host page may have global focus rules), disabled = 50% opacity, not-allowed cursor, no interaction.\n\nAccessibility: role=\"radiogroup\" with aria-label, role=\"radio\" buttons with aria-checked, the overlay copy is aria-hidden. Honour prefers-reduced-motion (matchMedia read inside the loop and in the move handler): no springs, no squish, no press growth; the thumb jumps to the target, and while dragging it tracks the pointer exactly. Add motion-reduce:transition-none to the CSS transitions. Touch targets: use size \"lg\" or override --sc-h to at least 40px on phones.\n\nOutput a single self-contained file.","license":"https://viberdy.dev/license"}}