{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "liquid-segments",
  "type": "registry:component",
  "title": "Liquid Segments",
  "description": "A row of options where the selected pill is a soft blob that stretches, splits and joins back up as it slides, and you can drag it straight to the one you want.",
  "categories": [
    "component",
    "Liquid"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/liquid-segments.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { motion, useReducedMotion, useSpring, useTransform } from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface LiquidSegmentsOption<T extends string = string> {\n  value: T;\n  label: ReactNode;\n  icon?: ReactNode;\n  count?: number;\n  disabled?: boolean;\n}\n\nexport interface LiquidSegmentsProps<T extends string = string> {\n  options: LiquidSegmentsOption<T>[];\n  value?: T;\n  defaultValue?: T;\n  onValueChange?: (value: T) => void;\n  size?: \"sm\" | \"default\";\n  fullWidth?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\ntype Rect = { x: number; w: number };\n\nexport function LiquidSegments<T extends string = string>({\n  options,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  size = \"default\",\n  fullWidth = false,\n  className,\n  \"aria-label\": ariaLabel = \"Options\",\n}: LiquidSegmentsProps<T>) {\n  const [inner, setInner] = useState<T>(defaultValue ?? options[0]?.value);\n  const value = valueProp ?? inner;\n  const selectedIndex = Math.max(\n    0,\n    options.findIndex((o) => o.value === value),\n  );\n  const reduce = useReducedMotion();\n  const filterId = `goo-seg-${useId().replace(/:/g, \"\")}`;\n\n  const trackRef = useRef<HTMLDivElement>(null);\n  const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const [rects, setRects] = useState<Rect[]>([]);\n  const [trackWidth, setTrackWidth] = useState(0);\n  const drag = useRef<{ startX: number; moved: boolean } | null>(null);\n  const suppressClick = useRef(false);\n  const [dragX, setDragX] = useState<number | null>(null);\n\n  const measure = useCallback(() => {\n    setRects(buttonRefs.current.map((b) => (b ? { x: b.offsetLeft, w: b.offsetWidth } : { x: 0, w: 0 })));\n    setTrackWidth(trackRef.current?.offsetWidth ?? 0);\n  }, []);\n\n  useLayoutEffect(() => {\n    measure();\n    const track = trackRef.current;\n    if (!track) return;\n    const observer = new ResizeObserver(measure);\n    observer.observe(track);\n    for (const b of buttonRefs.current) if (b) observer.observe(b);\n    return () => observer.disconnect();\n  }, [measure, options.length]);\n\n  const select = (index: number) => {\n    const option = options[index];\n    if (!option || option.disabled) return;\n    if (valueProp === undefined) setInner(option.value);\n    onValueChange?.(option.value);\n  };\n\n  const nearestIndex = (x: number) => {\n    let best = selectedIndex;\n    let bestDistance = Number.POSITIVE_INFINITY;\n    rects.forEach((r, i) => {\n      if (options[i]?.disabled) return;\n      const d = Math.abs(r.x + r.w / 2 - x);\n      if (d < bestDistance) {\n        bestDistance = d;\n        best = i;\n      }\n    });\n    return best;\n  };\n\n  const localX = (event: PointerEvent) => event.clientX - (trackRef.current?.getBoundingClientRect().left ?? 0);\n\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.button !== 0) return;\n    suppressClick.current = false;\n    drag.current = { startX: event.clientX, moved: false };\n  };\n\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const state = drag.current;\n    if (!state) return;\n    if (!state.moved && Math.abs(event.clientX - state.startX) > 4) {\n      state.moved = true;\n      event.currentTarget.setPointerCapture(event.pointerId);\n    }\n    if (state.moved) setDragX(localX(event));\n  };\n\n  const endDrag = (event: PointerEvent<HTMLDivElement>) => {\n    const state = drag.current;\n    drag.current = null;\n    if (!state?.moved) return;\n    suppressClick.current = true;\n    select(nearestIndex(localX(event)));\n    setDragX(null);\n  };\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    const step = event.key === \"ArrowRight\" || event.key === \"ArrowDown\" ? 1 : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\" ? -1 : 0;\n    let next = index;\n    if (step) {\n      for (let i = 1; i <= options.length; i++) {\n        const candidate = (index + step * i + options.length) % options.length;\n        if (!options[candidate]?.disabled) {\n          next = candidate;\n          break;\n        }\n      }\n    } else if (event.key === \"Home\") next = options.findIndex((o) => !o.disabled);\n    else if (event.key === \"End\") next = options.findLastIndex((o) => !o.disabled);\n    else return;\n    event.preventDefault();\n    select(next);\n    buttonRefs.current[next]?.focus();\n  };\n\n  const activeIndex = dragX === null ? selectedIndex : nearestIndex(dragX);\n  const settled = rects[activeIndex];\n  const lead = settled && dragX !== null ? { x: Math.min(Math.max(dragX - settled.w / 2, rects[0].x), (rects.at(-1)?.x ?? 0) + (rects.at(-1)?.w ?? 0) - settled.w), w: settled.w } : settled;\n\n  const leadX = useSpring(0, { stiffness: 520, damping: 34, mass: 0.8 });\n  const leadW = useSpring(0, { stiffness: 520, damping: 34, mass: 0.8 });\n  const trailX = useSpring(0, { stiffness: 230, damping: 24 });\n  const trailW = useSpring(0, { stiffness: 230, damping: 24 });\n  const placed = useRef(false);\n\n  useLayoutEffect(() => {\n    if (!lead || !settled || !settled.w) return;\n    const targets: [typeof leadX, number][] = [\n      [leadX, lead.x],\n      [leadW, lead.w],\n      [trailX, settled.x + settled.w * 0.1],\n      [trailW, settled.w * 0.8],\n    ];\n    for (const [value, to] of targets) {\n      if (!placed.current || reduce) value.jump(to);\n      else value.set(to);\n    }\n    placed.current = true;\n  }, [lead?.x, lead?.w, settled?.x, settled?.w, reduce, leadX, leadW, trailX, trailW]);\n\n  // The selected labels sit in a copy of the row, clipped to the blob, so text under the pill always reads.\n  const clipPath = useTransform([leadX, leadW, trailX, trailW], ([lx, lw, tx, tw]: number[]) => {\n    const left = Math.min(lx, tx);\n    const right = Math.max(lx + lw, tx + tw);\n    return `inset(4px ${Math.max(trackWidth - right, 0)}px 4px ${left}px round 999px)`;\n  });\n\n  const segmentClass = cn(\n    \"relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-full font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0\",\n    size === \"sm\" ? \"h-7 px-3 text-xs\" : \"h-8 px-3.5 text-sm\",\n    fullWidth && \"flex-1\",\n  );\n  const content = (option: LiquidSegmentsOption<T>, selected: boolean) => (\n    <>\n      {option.icon}\n      <span className={cn(fullWidth && option.icon && \"@max-sm:sr-only\")}>{option.label}</span>\n      {option.count !== undefined && (\n        <span\n          className={cn(\n            \"min-w-5 rounded-full px-1.5 py-px text-center font-mono text-[10px] tabular-nums leading-4\",\n            selected ? \"bg-primary-foreground/16\" : \"bg-foreground/8\",\n          )}\n        >\n          {option.count}\n        </span>\n      )}\n    </>\n  );\n\n  return (\n    <div\n      aria-label={ariaLabel}\n      className={cn(\n        \"relative isolate inline-flex touch-pan-y select-none rounded-full bg-muted p-1\",\n        fullWidth && \"@container flex w-full\",\n        className,\n      )}\n      onPointerCancel={endDrag}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={endDrag}\n      ref={trackRef}\n      role=\"radiogroup\"\n    >\n      <svg aria-hidden className=\"absolute size-0\">\n        <filter colorInterpolationFilters=\"sRGB\" height=\"200%\" id={filterId} width=\"140%\" x=\"-20%\" y=\"-50%\">\n          <feGaussianBlur in=\"SourceGraphic\" result=\"blur\" stdDeviation=\"5\" />\n          <feColorMatrix in=\"blur\" result=\"goo\" values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -8\" />\n          <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n        </filter>\n      </svg>\n      <span\n        aria-hidden\n        className={cn(\"pointer-events-none absolute inset-y-1 left-0 -z-10 w-full\", !settled && \"hidden\")}\n        style={{ filter: `url(#${filterId})` }}\n      >\n        <motion.span className=\"absolute inset-y-px left-0 rounded-full bg-primary\" style={{ x: trailX, width: trailW }} />\n        <motion.span className=\"absolute inset-y-0 left-0 rounded-full bg-primary\" style={{ x: leadX, width: leadW }} />\n      </span>\n      {options.map((option, index) => {\n        const checked = index === selectedIndex;\n        return (\n          <button\n            aria-checked={checked}\n            className={cn(\n              segmentClass,\n              \"cursor-pointer text-muted-foreground outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50\",\n            )}\n            disabled={option.disabled}\n            key={option.value}\n            onClick={() => {\n              if (suppressClick.current) {\n                suppressClick.current = false;\n                return;\n              }\n              select(index);\n            }}\n            onKeyDown={(event) => onKeyDown(event, index)}\n            ref={(node) => {\n              buttonRefs.current[index] = node;\n            }}\n            role=\"radio\"\n            tabIndex={checked ? 0 : -1}\n            type=\"button\"\n          >\n            {content(option, false)}\n          </button>\n        );\n      })}\n      <motion.div\n        aria-hidden\n        className={cn(\"pointer-events-none absolute inset-0 flex p-1 text-primary-foreground\", !settled && \"hidden\")}\n        style={{ clipPath }}\n      >\n        {options.map((option, index) => (\n          <span className={segmentClass} key={option.value}>\n            {content(option, true)}\n          </span>\n        ))}\n      </motion.div>\n    </div>\n  );\n}\n"
    }
  ]
}