{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "RubberSegment-JS-TW",
	"title": "RubberSegment",
	"description": "Segmented control whose rubber thumb stretches across the gap and squashes into place.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "RubberSegment/RubberSegment.jsx",
			"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from 'motion/react';\n\nconst EASE_OUT = [0.23, 1, 0.32, 1];\nconst SPRING_UI = { type: 'spring', duration: 0.3, bounce: 0 };\nconst SPRING_MOMENTUM = { type: 'spring', duration: 0.4, bounce: 0.2 };\nconst SPRING_RELAX = { type: 'spring', duration: 0.16, bounce: 0 };\nconst DILATE = 0.19;\nconst HANDOFF = 0.15;\nconst FLICK = 110;\nconst MAX_VELOCITY = 2000;\nconst DEADZONE = 4;\nconst SLOP = 10;\nconst RUBBER = 0.55;\nconst SIZES = {\n  sm: { height: 28, font: 12, pad: 10, min: 36 },\n  md: { height: 36, font: 13, pad: 14, min: 44 },\n  lg: { height: 44, font: 14, pad: 18, min: 48 }\n};\n\nconst clamp = (value, min, max) => Math.min(max, Math.max(min, value));\nconst rubber = (over, dim) => (over * dim * RUBBER) / (dim + RUBBER * Math.abs(over));\nconst project = (v, glide) => {\n  const d = 1 - 0.1 * Math.pow(0.05, glide / 100);\n  return ((v / 1000) * d) / (1 - d);\n};\nconst velocityOf = (hist, now) => {\n  const recent = hist.filter(([t]) => now - t <= 100);\n  if (recent.length < 2) return 0;\n  const [t0, x0] = recent[0];\n  const [t1, x1] = recent[recent.length - 1];\n  return t1 - t0 >= 8 ? ((x1 - x0) / (t1 - t0)) * 1000 : 0;\n};\nconst nearestSlot = (slots, x) => {\n  let best = 0;\n  for (let i = 1; i < slots.length; i++) {\n    if (Math.abs((slots[i].l + slots[i].r) / 2 - x) < Math.abs((slots[best].l + slots[best].r) / 2 - x)) best = i;\n  }\n  return best;\n};\n\nexport default function RubberSegment({\n  items,\n  value,\n  defaultValue,\n  onChange,\n  trackColor = '#27272a',\n  thumbColor = '#fafafa',\n  textColor = '#fafafa',\n  activeTextColor = '#18181b',\n  size = 'md',\n  radius = 10,\n  inset = 3,\n  equalSlots = true,\n  stretch = 100,\n  squash = 3,\n  speed = 1,\n  glide = 75,\n  draggable = true,\n  disabled = false,\n  className = '',\n  'aria-label': ariaLabel = 'Segmented control'\n}) {\n  const list = items.map(item => (typeof item === 'string' ? { value: item, label: item } : item));\n  const [inner, setInner] = useState(defaultValue ?? list[0]?.value);\n  const current = value !== undefined ? value : inner;\n  const index = Math.max(\n    0,\n    list.findIndex(item => item.value === current)\n  );\n  const reduce = useReducedMotion();\n\n  const trackRef = useRef(null);\n  const itemRefs = useRef([]);\n  const slots = useRef([]);\n  const box = useRef(null);\n  const committed = useRef(index);\n  const handoff = useRef(0);\n  const drag = useRef(null);\n  const gen = useRef(0);\n\n  const edgeL = useMotionValue(0);\n  const edgeR = useMotionValue(0);\n  const innerW = useMotionValue(0);\n  const thumbRadius = Math.max(0, radius - inset);\n  const clipPath = useTransform(\n    () => `inset(0 ${Math.max(0, innerW.get() - edgeR.get())}px 0 ${Math.max(0, edgeL.get())}px round ${thumbRadius}px)`\n  );\n\n  const t = seconds => seconds / speed;\n\n  const jumpTo = i => {\n    const s = slots.current[i];\n    if (!s) return;\n    clearTimeout(handoff.current);\n    gen.current += 1;\n    edgeL.jump(s.l);\n    edgeR.jump(s.r);\n  };\n\n  const measure = () => {\n    const track = trackRef.current;\n    if (!track) return;\n    const rect = track.getBoundingClientRect();\n    box.current = rect;\n    slots.current = list.map((_, i) => {\n      const el = itemRefs.current[i];\n      if (!el) return { l: 0, r: 0 };\n      const r = el.getBoundingClientRect();\n      return { l: r.left - rect.left - inset, r: r.right - rect.left - inset };\n    });\n    innerW.set(rect.width - inset * 2);\n    jumpTo(committed.current);\n  };\n\n  const listKey = list.map(item => item.value).join('|');\n  useLayoutEffect(() => {\n    measure();\n    const observer = new ResizeObserver(measure);\n    if (trackRef.current) observer.observe(trackRef.current);\n    if (typeof document !== 'undefined' && document.fonts) document.fonts.ready.then(measure);\n    return () => observer.disconnect();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [listKey, size, inset, equalSlots]);\n\n  useEffect(() => {\n    if (!drag.current && committed.current !== index) {\n      committed.current = index;\n      jumpTo(index);\n    }\n  });\n\n  useEffect(\n    () => () => {\n      clearTimeout(handoff.current);\n      edgeL.stop();\n      edgeR.stop();\n    },\n    [edgeL, edgeR]\n  );\n\n  const commit = i => {\n    committed.current = i;\n    if (i === index) return;\n    if (value === undefined) setInner(list[i].value);\n    onChange?.(list[i].value, i);\n  };\n\n  const land = (to, v, flick, withSquash) => {\n    const b = slots.current[to];\n    if (!b) return;\n    const g = ++gen.current;\n    const dir = Math.sign((b.l + b.r) / 2 - (edgeL.get() + edgeR.get()) / 2) || 1;\n    const [lead, leadTo, trail, trailTo] = dir > 0 ? [edgeR, b.r, edgeL, b.l] : [edgeL, b.l, edgeR, b.r];\n    const velocityFor = mv => clamp(v === null ? mv.getVelocity() : v, -MAX_VELOCITY, MAX_VELOCITY);\n    animate(lead, leadTo, {\n      ...(flick ? SPRING_MOMENTUM : SPRING_UI),\n      duration: t(flick ? 0.4 : 0.3),\n      velocity: velocityFor(lead)\n    });\n    const trailVelocity = velocityFor(trail);\n    if (!withSquash || squash <= 0) {\n      animate(trail, trailTo, { ...SPRING_UI, duration: t(0.3), velocity: trailVelocity });\n      return;\n    }\n    animate(trail, trailTo + dir * squash, { ...SPRING_UI, duration: t(0.3), velocity: trailVelocity }).then(() => {\n      if (gen.current === g) animate(trail, trailTo, { ...SPRING_RELAX, duration: t(0.16) });\n    });\n  };\n\n  const travel = (from, to) => {\n    const a = slots.current[from];\n    const b = slots.current[to];\n    if (!a || !b) return;\n    clearTimeout(handoff.current);\n    gen.current += 1;\n    if (reduce) {\n      edgeL.jump(b.l);\n      edgeR.jump(b.r);\n      return;\n    }\n    const u = stretch / 100;\n    const tween = { duration: t(DILATE), ease: EASE_OUT };\n    animate(edgeL, b.l + (Math.min(a.l, b.l) - b.l) * u, tween);\n    animate(edgeR, b.r + (Math.max(a.r, b.r) - b.r) * u, tween);\n    handoff.current = setTimeout(() => land(to, null, false, true), t(HANDOFF) * 1000);\n  };\n\n  const localX = e => e.clientX - (box.current ? box.current.left : 0) - inset;\n\n  const handlePointerDown = (e, i) => {\n    if (disabled || drag.current || e.button !== 0) return;\n    box.current = trackRef.current.getBoundingClientRect();\n    try {\n      e.currentTarget.setPointerCapture(e.pointerId);\n    } catch {}\n    const x = localX(e);\n    const onThumb = draggable && x >= edgeL.get() && x <= edgeR.get();\n    drag.current = { id: e.pointerId, x0: x, slot: i, onThumb, live: false, offset: 0, w: 0, hist: [[e.timeStamp, x]] };\n    if (onThumb) {\n      clearTimeout(handoff.current);\n      gen.current += 1;\n      edgeL.stop();\n      edgeR.stop();\n    } else if (!reduce) {\n      e.currentTarget.dataset.pressed = '';\n    }\n  };\n\n  const handlePointerMove = e => {\n    const d = drag.current;\n    if (!d || e.pointerId !== d.id || !d.onThumb) return;\n    const x = localX(e);\n    d.hist.push([e.timeStamp, x]);\n    if (d.hist.length > 8) d.hist.shift();\n    if (!d.live) {\n      if (Math.abs(x - d.x0) < DEADZONE) return;\n      d.live = true;\n      d.offset = x - edgeL.get();\n      d.w = edgeR.get() - edgeL.get();\n      if (trackRef.current) trackRef.current.dataset.held = '';\n    }\n    const width = innerW.get();\n    const l = x - d.offset;\n    const maxL = width - d.w;\n    if (reduce) {\n      const c = clamp(l, 0, maxL);\n      edgeL.set(c);\n      edgeR.set(c + d.w);\n    } else if (l < 0) {\n      edgeL.set(0);\n      edgeR.set(d.w - rubber(-l, d.w));\n    } else if (l > maxL) {\n      edgeR.set(width);\n      edgeL.set(maxL + rubber(l - maxL, d.w));\n    } else {\n      edgeL.set(l);\n      edgeR.set(l + d.w);\n    }\n  };\n\n  const release = () => {\n    const d = drag.current;\n    drag.current = null;\n    if (trackRef.current) delete trackRef.current.dataset.held;\n    const el = itemRefs.current[d.slot];\n    if (el) delete el.dataset.pressed;\n    return d;\n  };\n\n  const handlePointerUp = e => {\n    const d = drag.current;\n    if (!d || e.pointerId !== d.id) return;\n    release();\n    const x = localX(e);\n    if (!d.live) {\n      if (Math.abs(x - d.x0) <= SLOP && d.slot !== committed.current) {\n        const from = committed.current;\n        commit(d.slot);\n        travel(from, d.slot);\n      }\n      return;\n    }\n    const v = velocityOf(d.hist, e.timeStamp);\n    const flick = Math.abs(v) > FLICK;\n    let to = nearestSlot(slots.current, (edgeL.get() + edgeR.get()) / 2 + project(v, glide));\n    if (flick && to === committed.current) to = clamp(to + Math.sign(v), 0, list.length - 1);\n    commit(to);\n    if (reduce) jumpTo(to);\n    else land(to, v, flick, flick);\n  };\n\n  const handlePointerCancel = e => {\n    const d = drag.current;\n    if (!d || e.pointerId !== d.id) return;\n    release();\n    if (!d.live) return;\n    if (reduce) jumpTo(committed.current);\n    else land(committed.current, null, false, false);\n  };\n\n  const handleKeyDown = e => {\n    if (disabled) return;\n    const last = list.length - 1;\n    let next = null;\n    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = Math.min(last, index + 1);\n    else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = Math.max(0, index - 1);\n    else if (e.key === 'Home') next = 0;\n    else if (e.key === 'End') next = last;\n    if (next === null) return;\n    e.preventDefault();\n    if (next === index) return;\n    commit(next);\n    jumpTo(next);\n    itemRefs.current[next]?.focus();\n  };\n\n  const preset = SIZES[size] || SIZES.md;\n\n  return (\n    <div\n      ref={trackRef}\n      role=\"radiogroup\"\n      aria-label={ariaLabel}\n      aria-disabled={disabled || undefined}\n      data-equal={equalSlots ? '' : undefined}\n      data-draggable={draggable && !disabled ? '' : undefined}\n      className={`group relative inline-grid grid-flow-col auto-cols-auto align-middle select-none touch-pan-y [font-family:inherit] [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] p-[var(--rs-inset)] rounded-[var(--rs-radius)] [background:var(--rs-track)] data-[equal]:auto-cols-[minmax(0,1fr)] data-[held]:cursor-grabbing aria-disabled:pointer-events-none aria-disabled:opacity-50${className ? ` ${className}` : ''}`}\n      style={{\n        '--rs-track': trackColor,\n        '--rs-thumb': thumbColor,\n        '--rs-ink': textColor,\n        '--rs-ink-active': activeTextColor,\n        '--rs-radius': `${radius}px`,\n        '--rs-inset': `${inset}px`,\n        '--rs-thumb-radius': `${thumbRadius}px`,\n        '--rs-h': `${preset.height}px`,\n        '--rs-font': `${preset.font}px`,\n        '--rs-pad': `${preset.pad}px`,\n        '--rs-min': `${preset.min}px`,\n        '--rs-ease-out': 'cubic-bezier(0.23, 1, 0.32, 1)'\n      }}\n      onPointerMove={handlePointerMove}\n      onPointerUp={handlePointerUp}\n      onPointerCancel={handlePointerCancel}\n      onLostPointerCapture={handlePointerCancel}\n    >\n      {list.map((item, i) => (\n        <button\n          key={item.value}\n          ref={el => {\n            itemRefs.current[i] = el;\n          }}\n          type=\"button\"\n          role=\"radio\"\n          aria-checked={i === index}\n          tabIndex={i === index ? 0 : -1}\n          disabled={disabled}\n          className=\"inline-flex h-[calc(var(--rs-h)-var(--rs-inset)*2)] min-w-[var(--rs-min)] items-center justify-center gap-1.5 m-0 border-0 bg-transparent px-[var(--rs-pad)] py-0 rounded-[var(--rs-thumb-radius)] [font:inherit] text-[length:var(--rs-font)] font-medium leading-none whitespace-nowrap outline-none [transition:opacity_160ms_ease,transform_160ms_var(--rs-ease-out)] motion-reduce:[transition:opacity_160ms_ease] cursor-pointer [color:var(--rs-ink)] opacity-70 aria-checked:cursor-default group-data-[draggable]:aria-checked:cursor-grab group-data-[held]:cursor-grabbing data-[pressed]:[transform:scale(0.96)] focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:[outline-color:var(--rs-thumb)] [@media(hover:hover)_and_(pointer:fine)]:[&[aria-checked=false]:hover]:opacity-90\"\n          onPointerDown={e => handlePointerDown(e, i)}\n          onKeyDown={handleKeyDown}\n        >\n          {item.icon}\n          {item.label}\n        </button>\n      ))}\n      <motion.div\n        className=\"pointer-events-none absolute inset-[var(--rs-inset)] grid grid-flow-col auto-cols-auto group-data-[equal]:auto-cols-[minmax(0,1fr)] [background:var(--rs-thumb)] [color:var(--rs-ink-active)]\"\n        aria-hidden=\"true\"\n        style={{ clipPath }}\n      >\n        {list.map(item => (\n          <span\n            key={item.value}\n            className=\"inline-flex h-[calc(var(--rs-h)-var(--rs-inset)*2)] min-w-[var(--rs-min)] items-center justify-center gap-1.5 m-0 border-0 bg-transparent px-[var(--rs-pad)] py-0 rounded-[var(--rs-thumb-radius)] [font:inherit] text-[length:var(--rs-font)] font-medium leading-none whitespace-nowrap outline-none [transition:opacity_160ms_ease,transform_160ms_var(--rs-ease-out)] motion-reduce:[transition:opacity_160ms_ease] cursor-default [color:inherit]\"\n          >\n            {item.icon}\n            {item.label}\n          </span>\n        ))}\n      </motion.div>\n    </div>\n  );\n}\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"motion@^12.23.12"
	]
}