{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "glass-segmented-control",
  "title": "Glass Segmented Control",
  "description": "A single-choice control whose glass bubble stretches between options and can be dragged.",
  "dependencies": [
    "@radix-ui/react-radio-group",
    "clsx"
  ],
  "registryDependencies": [
    "@glassyui/glass-provider",
    "@glassyui/glass-panel"
  ],
  "files": [
    {
      "path": "registry/glassyui/glass-segmented-control.tsx",
      "content": "'use client';\n\nimport * as RadioGroupPrimitive from '@radix-ui/react-radio-group';\nimport clsx from 'clsx';\nimport { forwardRef, useMemo, useRef } from 'react';\nimport { useLiquidIndicator } from './lib/liquid-motion';\nimport { mergeRefs } from './lib/merge-refs';\nimport { GlassPanel } from './glass-panel';\n\nexport type GlassSegmentedControlProps = React.ComponentPropsWithoutRef<\n  typeof RadioGroupPrimitive.Root\n>;\n\nconst INDICATOR = {\n  selected: '[role=\"radio\"][data-state=\"checked\"]',\n  item: '[role=\"radio\"]',\n  draggable: true,\n  deformation: 0.25,\n};\n\n/**\n * A single-choice control for two to six options. CSS places the indicator from the\n * checked item, so it is correct from the first server render; once scripts run it\n * moves like a drop of glass and can be dragged to another option.\n */\nexport const GlassSegmentedControl = forwardRef<HTMLDivElement, GlassSegmentedControlProps>(\n  ({ children, className, ...props }, forwardedRef) => {\n    const rootRef = useRef<HTMLDivElement | null>(null);\n    const indicatorRef = useRef<HTMLSpanElement | null>(null);\n    const ref = useMemo(() => mergeRefs(rootRef, forwardedRef), [forwardedRef]);\n\n    useLiquidIndicator(rootRef, indicatorRef, INDICATOR);\n\n    return (\n      <RadioGroupPrimitive.Root ref={ref} {...props} asChild>\n        <GlassPanel\n          variant=\"pill\"\n          preset=\"control\"\n          refraction=\"none\"\n          className={clsx('lg-segmented', className)}\n        >\n          <span ref={indicatorRef} aria-hidden className=\"lg-segmented-indicator\" />\n          {children}\n        </GlassPanel>\n      </RadioGroupPrimitive.Root>\n    );\n  },\n);\n\nGlassSegmentedControl.displayName = 'GlassSegmentedControl';\n\nexport const GlassSegmentedControlItem = forwardRef<\n  HTMLButtonElement,\n  React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>\n>(({ className, ...props }, forwardedRef) => (\n  <RadioGroupPrimitive.Item\n    ref={forwardedRef}\n    className={clsx('lg-segmented-item', className)}\n    {...props}\n  />\n));\n\nGlassSegmentedControlItem.displayName = 'GlassSegmentedControlItem';\n",
      "type": "registry:ui",
      "target": "components/glassyui/glass-segmented-control.tsx"
    },
    {
      "path": "registry/glassyui/lib/merge-refs.ts",
      "content": "import type { Ref } from 'react';\n\nexport function mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T) => {\n    for (const ref of refs) {\n      if (!ref) continue;\n\n      if ('current' in ref) {\n        try {\n          ref.current = node;\n        } catch {\n          // Read-only refs cannot be assigned.\n        }\n      } else {\n        ref(node);\n      }\n    }\n  };\n}\n",
      "type": "registry:lib",
      "target": "components/glassyui/lib/merge-refs.ts"
    },
    {
      "path": "registry/glassyui/lib/liquid-motion.ts",
      "content": "'use client';\n\nimport { useLayoutEffect } from 'react';\nimport type { RefObject } from 'react';\n\nexport type Spring = {\n  value: number;\n  velocity: number;\n  target: number;\n  stiffness: number;\n  damping: number;\n  precision: number;\n};\n\n/*\n * Springs take a response and a bounce: response is the period of the\n * undamped oscillation in seconds, and bounce 0 is critically damped. For\n * unit mass that is stiffness (2π / response)² and damping\n * 4π (1 - bounce) / response.\n */\nexport const createSpring = (\n  response: number,\n  bounce: number,\n  value: number,\n  precision = 0.001,\n): Spring => ({\n  value,\n  velocity: 0,\n  target: value,\n  stiffness: ((2 * Math.PI) / response) ** 2,\n  damping: (4 * Math.PI * (1 - bounce)) / response,\n  precision,\n});\n\n// Changes how a spring moves without stopping it, so it carries its velocity\n// into the new motion.\nexport const retuneSpring = (spring: Spring, response: number, bounce: number) => {\n  spring.stiffness = ((2 * Math.PI) / response) ** 2;\n  spring.damping = (4 * Math.PI * (1 - bounce)) / response;\n};\n\n// Semi-implicit Euler in steps of at most 1/120 s is stable for every spring\n// here. Returns whether the spring is still moving.\nexport const stepSpring = (spring: Spring, dt: number, snap: boolean) => {\n  if (snap || (spring.value === spring.target && spring.velocity === 0)) {\n    spring.value = spring.target;\n    spring.velocity = 0;\n\n    return false;\n  }\n\n  const steps = Math.max(1, Math.ceil(dt * 120));\n  const step = dt / steps;\n\n  for (let index = 0; index < steps; index += 1) {\n    spring.velocity +=\n      (-spring.stiffness * (spring.value - spring.target) - spring.damping * spring.velocity) *\n      step;\n    spring.value += spring.velocity * step;\n  }\n\n  if (\n    Math.abs(spring.value - spring.target) < spring.precision &&\n    Math.abs(spring.velocity) < spring.precision * 10\n  ) {\n    spring.value = spring.target;\n    spring.velocity = 0;\n\n    return false;\n  }\n\n  return true;\n};\n\nlet motionQuery: MediaQueryList | undefined;\n\n// The provider mirrors its preference onto <html>; the system setting always\n// counts, as it does in liquid.css.\nexport function motionReduced() {\n  motionQuery ??= window.matchMedia('(prefers-reduced-motion: reduce)');\n\n  return (\n    document.documentElement.getAttribute('data-lg-reduce-motion') === '1' || motionQuery.matches\n  );\n}\n\nexport type FrameLoop = {\n  start: () => void;\n  stop: () => void;\n};\n\n// Calls step once a frame with the seconds since the last one until it\n// returns false. A long pause counts as one short frame.\nexport function createFrameLoop(step: (dt: number) => boolean): FrameLoop {\n  let frame = 0;\n  let last = 0;\n\n  const tick = (now: number) => {\n    const dt = last ? Math.min((now - last) / 1000, 1 / 20) : 1 / 60;\n\n    last = now;\n    frame = 0;\n\n    if (step(dt)) {\n      frame = requestAnimationFrame(tick);\n\n      return;\n    }\n\n    last = 0;\n  };\n\n  return {\n    start() {\n      if (!frame) frame = requestAnimationFrame(tick);\n    },\n    stop() {\n      cancelAnimationFrame(frame);\n      frame = 0;\n      last = 0;\n    },\n  };\n}\n\ntype Edge = 'left' | 'top' | 'right' | 'bottom';\n\ntype Box = Record<Edge, number>;\n\ntype Feel = readonly [response: number, bounce: number];\n\nconst EDGES: readonly Edge[] = ['left', 'top', 'right', 'bottom'];\n\n// The edge heading for the new item leads and the far edge follows, so the\n// bubble stretches into a drop on the way and rounds off when it lands. The\n// leading edge overshoots a little and rings back, which is what makes the\n// landing feel like a drop settling rather than a slide stopping.\nconst LEAD: Feel = [0.2, 0.32];\n\nconst TRAIL: Feel = [0.3, 0.12];\n\nconst EVEN: Feel = [0.26, 0.22];\n\n// Under a finger the bubble keeps up closely but still leans into the drag.\nconst DRAG_LEAD: Feel = [0.1, 0.12];\n\nconst DRAG_TRAIL: Feel = [0.18, 0.04];\n\n// How far a lifted bubble grows past its item, in CSS pixels. It never grows\n// past the control's own edge. Kept to a hair: more reads as the bubble\n// zooming out of its track.\nconst SWELL = 1;\n\n// How much a lifted bubble magnifies the label under it.\nconst MAGNIFY = 0.04;\n\nconst DRAG_THRESHOLD = 4;\n\n// The bubble counts as travelling, and stays lifted, while an edge moves faster\n// than this in CSS pixels a second, so it settles the moment it slows down.\nconst TRAVEL_SPEED = 90;\n\n// How far a press on another item wakes the bubble before it moves.\nconst ANTICIPATION = 0.5;\n\ntype Frame = { element: HTMLElement; rect: DOMRect; scale: number };\n\n// The element the bubble is positioned in, with the scale of any scaled\n// ancestor, such as a dialog that is still opening. offsetWidth is rounded,\n// so a ratio within that rounding of 1 is no scaling at all.\nfunction measureFrame(element: HTMLElement): Frame {\n  const rect = element.getBoundingClientRect();\n  const ratio = rect.width / Math.max(element.offsetWidth, 1);\n\n  return { element, rect, scale: Math.abs(ratio - 1) * element.offsetWidth <= 0.5 ? 1 : ratio };\n}\n\n// Client rectangles keep the sub-pixel positions that layout offsets round\n// away, so the bubble lines up exactly. The magnification painted on an item\n// scales it about its centre and is divided back out. The bubble is placed in\n// the frame's scrolled content, so any scroll is added back.\nfunction boxWithin(element: HTMLElement, frame: Frame): Box {\n  const rect = element.getBoundingClientRect();\n  const own = Number(element.style.scale) || 1;\n  const width = rect.width / frame.scale / own;\n  const height = rect.height / frame.scale / own;\n  const { clientLeft, clientTop, scrollLeft, scrollTop } = frame.element;\n\n  const centreX =\n    (rect.left + rect.width / 2 - frame.rect.left) / frame.scale - clientLeft + scrollLeft;\n\n  const centreY =\n    (rect.top + rect.height / 2 - frame.rect.top) / frame.scale - clientTop + scrollTop;\n\n  return {\n    left: centreX - width / 2,\n    top: centreY - height / 2,\n    right: centreX + width / 2,\n    bottom: centreY + height / 2,\n  };\n}\n\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\n\nexport type LiquidIndicatorOptions = {\n  /** Matches the item the bubble sits under. */\n  selected: string;\n  /** Matches every item the bubble can move to. */\n  item: string;\n  /** Lets a pointer pick the bubble up and drop it on another item. */\n  draggable?: boolean;\n  /** Strength of the bubble's stretch, lift and label magnification. */\n  deformation?: number;\n};\n\n/**\n * Moves a selection bubble between items like a drop of liquid glass: it\n * lifts, stretches toward the new item, lands with a little bounce and\n * magnifies the label under it while lifted. CSS places the bubble until this\n * runs. Reduced motion jumps instead.\n */\nexport function useLiquidIndicator(\n  rootRef: RefObject<HTMLElement | null>,\n  indicatorRef: RefObject<HTMLElement | null>,\n  { selected, item, draggable = false, deformation = 1 }: LiquidIndicatorOptions,\n) {\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    const indicator = indicatorRef.current;\n\n    if (!root || !indicator) return;\n\n    const edges: Record<Edge, Spring> = {\n      left: createSpring(...EVEN, 0, 0.05),\n      top: createSpring(...EVEN, 0, 0.05),\n      right: createSpring(...EVEN, 0, 0.05),\n      bottom: createSpring(...EVEN, 0, 0.05),\n    };\n\n    const lift = createSpring(0.22, 0.2, 0);\n    const painted = new Map<HTMLElement, string>();\n    let items: HTMLElement[] = [];\n    let centres: number[] = [];\n    let widths: number[] = [];\n    let room = 0;\n    let placed = false;\n    let held: Element | null = null;\n    let drag: { pointer: number; startX: number; offset: number; active: boolean } | null = null;\n\n    const frameOf = () =>\n      indicator.offsetParent instanceof HTMLElement ? indicator.offsetParent : root;\n\n    const paint = () => {\n      const width = edges.right.value - edges.left.value;\n      const height = edges.bottom.value - edges.top.value;\n      const restWidth = Math.max(edges.right.target - edges.left.target, 1);\n      const restHeight = Math.max(edges.bottom.target - edges.top.target, 1);\n      // A stretched drop keeps its volume, so it thins across its travel.\n      const thinY = clamp(1 / Math.sqrt(Math.max(width / restWidth, 1)), 0.8, 1);\n      const thinX = clamp(1 / Math.sqrt(Math.max(height / restHeight, 1)), 0.8, 1);\n      // A spring that rings past rest must not lift the bubble below its resting size.\n      const raised = Math.max(lift.value, 0) * deformation;\n      const swell = raised * Math.min(room, SWELL);\n      const drawnWidth = restWidth + (width * thinX - restWidth) * deformation + swell * 2;\n      const drawnHeight = restHeight + (height * thinY - restHeight) * deformation + swell * 2;\n      const centreX = (edges.left.value + edges.right.value) / 2;\n      const centreY = (edges.top.value + edges.bottom.value) / 2;\n\n      indicator.style.translate = `${centreX - drawnWidth / 2}px ${centreY - drawnHeight / 2}px`;\n      indicator.style.width = `${drawnWidth}px`;\n      indicator.style.height = `${drawnHeight}px`;\n      indicator.style.setProperty('--lg-lift', raised.toFixed(3));\n\n      items.forEach((element, index) => {\n        const nearness = Math.max(\n          0,\n          1 - Math.abs(centres[index] - centreX) / Math.max(widths[index], 1),\n        );\n\n        const magnify = 1 + raised * MAGNIFY * nearness;\n        const scale = magnify > 1.0005 ? magnify.toFixed(4) : '';\n\n        if ((painted.get(element) ?? '') === scale) return;\n\n        painted.set(element, scale);\n        element.style.scale = scale;\n      });\n    };\n\n    const step = (dt: number) => {\n      const reduced = motionReduced();\n      const travelling = EDGES.some((edge) => Math.abs(edges[edge].velocity) > TRAVEL_SPEED);\n      const pressed = held?.matches(selected) ?? false;\n      const woken = held !== null ? ANTICIPATION : 0;\n\n      lift.target = reduced ? 0 : pressed || drag?.active || travelling ? 1 : woken;\n      let moving = false;\n\n      for (const edge of EDGES) {\n        moving = stepSpring(edges[edge], dt, reduced) || moving;\n      }\n\n      moving = stepSpring(lift, dt, reduced) || moving;\n      paint();\n\n      return moving;\n    };\n\n    const loop = createFrameLoop(step);\n\n    const current = (): Box => ({\n      left: edges.left.value,\n      top: edges.top.value,\n      right: edges.right.value,\n      bottom: edges.bottom.value,\n    });\n\n    // Tunes each axis so the edge in front leads and the one behind trails.\n    const lean = (from: Box, to: Box, lead: Feel, trail: Feel) => {\n      const axes: [Edge, Edge, number][] = [\n        ['left', 'right', (to.left + to.right - from.left - from.right) / 2],\n        ['top', 'bottom', (to.top + to.bottom - from.top - from.bottom) / 2],\n      ];\n\n      for (const [start, end, distance] of axes) {\n        if (Math.abs(distance) < 1) {\n          retuneSpring(edges[start], ...EVEN);\n          retuneSpring(edges[end], ...EVEN);\n        } else {\n          retuneSpring(edges[distance > 0 ? end : start], ...lead);\n          retuneSpring(edges[distance > 0 ? start : end], ...trail);\n        }\n      }\n    };\n\n    const aim = (to: Box, jump: boolean) => {\n      for (const edge of EDGES) {\n        edges[edge].target = to[edge];\n\n        if (jump) {\n          edges[edge].value = to[edge];\n          edges[edge].velocity = 0;\n        }\n      }\n    };\n\n    const sync = (snap: boolean) => {\n      const frame = measureFrame(frameOf());\n      const target = root.querySelector<HTMLElement>(selected);\n\n      items = Array.from(root.querySelectorAll<HTMLElement>(item));\n\n      const boxes = items.map((element) => boxWithin(element, frame));\n\n      centres = boxes.map((box) => (box.left + box.right) / 2);\n      widths = boxes.map((box) => box.right - box.left);\n\n      painted.forEach((_, element) => {\n        if (items.includes(element)) return;\n\n        element.style.removeProperty('scale');\n        painted.delete(element);\n      });\n\n      // A pointer owns the bubble until it lets go.\n      if (drag?.active) return;\n\n      if (!target) {\n        indicator.setAttribute('data-lg-empty', '');\n        placed = false;\n\n        return;\n      }\n\n      const box = boxWithin(target, frame);\n      const jump = snap || !placed || motionReduced();\n\n      indicator.removeAttribute('data-lg-empty');\n      room = Math.max(0, Math.min(box.top, frame.element.clientHeight - box.bottom));\n\n      if (!jump) lean(current(), box, LEAD, TRAIL);\n\n      aim(box, jump);\n      placed = true;\n\n      if (jump) paint();\n\n      loop.start();\n    };\n\n    const toLocalX = (clientX: number) => {\n      const frame = measureFrame(frameOf());\n\n      return (\n        (clientX - frame.rect.left) / frame.scale -\n        frame.element.clientLeft +\n        frame.element.scrollLeft\n      );\n    };\n\n    const follow = (clientX: number) => {\n      if (!drag || centres.length === 0) return;\n\n      const width = edges.right.target - edges.left.target;\n\n      const centre = clamp(\n        toLocalX(clientX) - drag.offset,\n        Math.min(...centres),\n        Math.max(...centres),\n      );\n\n      const from = current();\n      const to = { ...from, left: centre - width / 2, right: centre + width / 2 };\n\n      lean(from, to, DRAG_LEAD, DRAG_TRAIL);\n      aim({ ...to, top: edges.top.target, bottom: edges.bottom.target }, motionReduced());\n      loop.start();\n    };\n\n    // Drops a dragged bubble on the nearest enabled item, or puts it back.\n    const release = (commit: boolean) => {\n      if (!drag) return;\n\n      const { active, pointer } = drag;\n\n      drag = null;\n      held = null;\n      root.removeAttribute('data-lg-dragging');\n\n      if (root.hasPointerCapture(pointer)) root.releasePointerCapture(pointer);\n\n      if (active && commit) {\n        const centre = (edges.left.target + edges.right.target) / 2;\n        let nearest = -1;\n\n        centres.forEach((value, index) => {\n          if (items[index].matches('[data-disabled]')) return;\n\n          if (nearest < 0 || Math.abs(value - centre) < Math.abs(centres[nearest] - centre))\n            nearest = index;\n        });\n\n        if (nearest >= 0) {\n          if (root.contains(document.activeElement)) items[nearest].focus({ preventScroll: true });\n\n          items[nearest].click();\n        }\n      }\n\n      sync(false);\n    };\n\n    const onPointerDown = (event: PointerEvent) => {\n      if (!event.isPrimary || event.button !== 0 || !(event.target instanceof Element)) return;\n\n      const pressed = event.target.closest(item);\n\n      if (\n        !(pressed instanceof HTMLElement) ||\n        !root.contains(pressed) ||\n        pressed.matches('[data-disabled]')\n      )\n        return;\n\n      held = pressed;\n      loop.start();\n\n      if (!draggable || !pressed.matches(selected)) return;\n\n      const centre = (edges.left.target + edges.right.target) / 2;\n\n      drag = {\n        pointer: event.pointerId,\n        startX: event.clientX,\n        offset: toLocalX(event.clientX) - centre,\n        active: false,\n      };\n    };\n\n    const onPointerMove = (event: PointerEvent) => {\n      if (!drag || event.pointerId !== drag.pointer) return;\n\n      if (!drag.active) {\n        if (Math.abs(event.clientX - drag.startX) < DRAG_THRESHOLD) return;\n\n        drag.active = true;\n        root.setAttribute('data-lg-dragging', '');\n        root.setPointerCapture(event.pointerId);\n      }\n\n      follow(event.clientX);\n    };\n\n    // Listens on the window, so a press that ends outside the control still ends.\n    const onPointerUp = (event: PointerEvent) => {\n      if (drag && event.pointerId === drag.pointer) {\n        release(event.type === 'pointerup');\n\n        return;\n      }\n\n      if (!held) return;\n\n      held = null;\n      loop.start();\n    };\n\n    // Taking the pointer from a touched item fires this on the item; only the\n    // root losing it ends a drag.\n    const onLostCapture = (event: PointerEvent) => {\n      if (event.target === root && drag?.active && event.pointerId === drag.pointer) release(false);\n    };\n\n    const resizes = new ResizeObserver(() => sync(true));\n\n    const observeSizes = () => {\n      resizes.disconnect();\n      resizes.observe(root);\n      root.querySelectorAll<HTMLElement>(item).forEach((element) => resizes.observe(element));\n    };\n\n    const mutations = new MutationObserver((records) => {\n      if (records.some((record) => record.type === 'childList')) observeSizes();\n\n      sync(false);\n    });\n\n    root.setAttribute('data-lg-indicator', 'ready');\n    sync(true);\n    observeSizes();\n    mutations.observe(root, {\n      attributes: true,\n      attributeFilter: ['data-state', 'data-disabled'],\n      childList: true,\n      subtree: true,\n    });\n    root.addEventListener('pointerdown', onPointerDown);\n    root.addEventListener('pointermove', onPointerMove);\n    root.addEventListener('lostpointercapture', onLostCapture);\n    window.addEventListener('pointerup', onPointerUp);\n    window.addEventListener('pointercancel', onPointerUp);\n\n    return () => {\n      loop.stop();\n      mutations.disconnect();\n      resizes.disconnect();\n      root.removeEventListener('pointerdown', onPointerDown);\n      root.removeEventListener('pointermove', onPointerMove);\n      root.removeEventListener('lostpointercapture', onLostCapture);\n      window.removeEventListener('pointerup', onPointerUp);\n      window.removeEventListener('pointercancel', onPointerUp);\n      root.removeAttribute('data-lg-indicator');\n      root.removeAttribute('data-lg-dragging');\n      painted.forEach((_, element) => element.style.removeProperty('scale'));\n      indicator.removeAttribute('data-lg-empty');\n      ['translate', 'width', 'height', '--lg-lift'].forEach((property) =>\n        indicator.style.removeProperty(property),\n      );\n    };\n  }, [deformation, draggable, indicatorRef, item, rootRef, selected]);\n}\n",
      "type": "registry:lib",
      "target": "components/glassyui/lib/liquid-motion.ts"
    }
  ],
  "meta": {
    "sourceModules": [
      "segmented-control"
    ]
  },
  "type": "registry:ui"
}