{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "RubberSegment-TS-CSS",
	"title": "RubberSegment",
	"description": "Segmented control whose rubber thumb stretches across the gap and squashes into place.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "RubberSegment.css",
			"target": "@components/RubberSegment.css",
			"content": ".rubber-segment {\n  --rs-track: #27272a;\n  --rs-thumb: #fafafa;\n  --rs-ink: #fafafa;\n  --rs-ink-active: #18181b;\n  --rs-radius: 10px;\n  --rs-inset: 3px;\n  --rs-thumb-radius: 7px;\n  --rs-h: 36px;\n  --rs-font: 13px;\n  --rs-pad: 14px;\n  --rs-min: 44px;\n  --rs-idle: 0.7;\n  --rs-hover: 0.9;\n  --rs-ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n\n  position: relative;\n  display: inline-grid;\n  grid-auto-flow: column;\n  grid-auto-columns: auto;\n  vertical-align: middle;\n  padding: var(--rs-inset);\n  border-radius: var(--rs-radius);\n  background: var(--rs-track);\n  font-family: inherit;\n  touch-action: pan-y;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-touch-callout: none;\n  -webkit-tap-highlight-color: transparent;\n}\n\n.rubber-segment[data-equal] {\n  grid-auto-columns: minmax(0, 1fr);\n}\n\n.rubber-segment[aria-disabled='true'] {\n  opacity: 0.5;\n  pointer-events: none;\n}\n\n.rubber-segment__item {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  gap: 6px;\n  height: calc(var(--rs-h) - var(--rs-inset) * 2);\n  min-width: var(--rs-min);\n  margin: 0;\n  padding: 0 var(--rs-pad);\n  border: 0;\n  border-radius: var(--rs-thumb-radius);\n  background: none;\n  color: var(--rs-ink);\n  opacity: var(--rs-idle);\n  font: inherit;\n  font-size: var(--rs-font);\n  font-weight: 500;\n  line-height: 1;\n  white-space: nowrap;\n  cursor: pointer;\n  outline: none;\n  transition:\n    opacity 160ms ease,\n    transform 160ms var(--rs-ease-out);\n}\n\n.rubber-segment__item[aria-checked='true'] {\n  cursor: default;\n}\n\n.rubber-segment[data-draggable] .rubber-segment__item[aria-checked='true'] {\n  cursor: grab;\n}\n\n.rubber-segment[data-held],\n.rubber-segment[data-held] .rubber-segment__item {\n  cursor: grabbing;\n}\n\n.rubber-segment__item[data-pressed] {\n  transform: scale(0.96);\n}\n\n.rubber-segment__item:focus-visible {\n  outline: 2px solid var(--rs-thumb);\n  outline-offset: 3px;\n}\n\n@media (hover: hover) and (pointer: fine) {\n  .rubber-segment__item[aria-checked='false']:hover {\n    opacity: var(--rs-hover);\n  }\n}\n\n.rubber-segment__thumb {\n  position: absolute;\n  inset: var(--rs-inset);\n  display: grid;\n  grid-auto-flow: column;\n  grid-auto-columns: auto;\n  background: var(--rs-thumb);\n  color: var(--rs-ink-active);\n  pointer-events: none;\n}\n\n.rubber-segment[data-equal] .rubber-segment__thumb {\n  grid-auto-columns: minmax(0, 1fr);\n}\n\n.rubber-segment__copy {\n  color: inherit;\n  opacity: 1;\n  cursor: default;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .rubber-segment__item {\n    transition: opacity 160ms ease;\n  }\n}\n"
		},
		{
			"type": "registry:component",
			"path": "RubberSegment.tsx",
			"content": "'use client';\n\nimport React, { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from 'motion/react';\n\nimport './RubberSegment.css';\n\nexport type RubberSegmentSize = 'sm' | 'md' | 'lg';\nexport type RubberSegmentItem = string | { value: string; label: ReactNode; icon?: ReactNode };\n\nexport interface RubberSegmentProps {\n  items: RubberSegmentItem[];\n  value?: string;\n  defaultValue?: string;\n  onChange?: (value: string, index: number) => void;\n  trackColor?: string;\n  thumbColor?: string;\n  textColor?: string;\n  activeTextColor?: string;\n  size?: RubberSegmentSize;\n  radius?: number;\n  inset?: number;\n  equalSlots?: boolean;\n  stretch?: number;\n  squash?: number;\n  speed?: number;\n  glide?: number;\n  draggable?: boolean;\n  disabled?: boolean;\n  className?: string;\n  'aria-label'?: string;\n}\n\ntype Slot = { l: number; r: number };\ntype Sample = [number, number];\ntype Drag = {\n  id: number;\n  x0: number;\n  slot: number;\n  onThumb: boolean;\n  live: boolean;\n  offset: number;\n  w: number;\n  hist: Sample[];\n};\n\nconst EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];\nconst SPRING_UI = { type: 'spring' as const, duration: 0.3, bounce: 0 };\nconst SPRING_MOMENTUM = { type: 'spring' as const, duration: 0.4, bounce: 0.2 };\nconst SPRING_RELAX = { type: 'spring' as const, 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: Record<RubberSegmentSize, { height: number; font: number; pad: number; min: number }> = {\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: number, min: number, max: number) => Math.min(max, Math.max(min, value));\nconst rubber = (over: number, dim: number) => (over * dim * RUBBER) / (dim + RUBBER * Math.abs(over));\nconst project = (v: number, glide: number) => {\n  const d = 1 - 0.1 * Math.pow(0.05, glide / 100);\n  return ((v / 1000) * d) / (1 - d);\n};\nconst velocityOf = (hist: Sample[], now: number) => {\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: Slot[], x: number) => {\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\nconst RubberSegment: React.FC<RubberSegmentProps> = ({\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<string | undefined>(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<HTMLDivElement>(null);\n  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const slots = useRef<Slot[]>([]);\n  const box = useRef<DOMRect | null>(null);\n  const committed = useRef(index);\n  const handoff = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const drag = useRef<Drag | null>(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: number) => seconds / speed;\n\n  const jumpTo = (i: number) => {\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: number) => {\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: number, v: number | null, flick: boolean, withSquash: boolean) => {\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: MotionValue<number>) =>\n      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: number, to: number) => {\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: { clientX: number }) => e.clientX - (box.current ? box.current.left : 0) - inset;\n\n  const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>, i: number) => {\n    if (disabled || drag.current || e.button !== 0) return;\n    box.current = trackRef.current?.getBoundingClientRect() ?? null;\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: React.PointerEvent<HTMLDivElement>) => {\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 as Drag;\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: React.PointerEvent<HTMLDivElement>) => {\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: React.PointerEvent<HTMLDivElement>) => {\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: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (disabled) return;\n    const last = list.length - 1;\n    let next: number | null = 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={`rubber-segment${className ? ` ${className}` : ''}`}\n      style={\n        {\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        } as CSSProperties\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=\"rubber-segment__item\"\n          onPointerDown={e => handlePointerDown(e, i)}\n          onKeyDown={handleKeyDown}\n        >\n          {item.icon}\n          {item.label}\n        </button>\n      ))}\n      <motion.div className=\"rubber-segment__thumb\" aria-hidden=\"true\" style={{ clipPath }}>\n        {list.map(item => (\n          <span key={item.value} className=\"rubber-segment__item rubber-segment__copy\">\n            {item.icon}\n            {item.label}\n          </span>\n        ))}\n      </motion.div>\n    </div>\n  );\n};\n\nexport default RubberSegment;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"motion@^12.23.12"
	]
}