{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ratio-balancer","type":"registry:component","title":"Ratio balancer","description":"A multi-segment allocation track. Drag dividers to rebalance constrained percentages with lockable pins.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/ratio-balancer.tsx","type":"registry:component","content":"// Hairline UI · ratio-balancer · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\n/* ─────────────────────────────────────────────────────────\n * RATIO BALANCER: constrained multi-segment resource allocator\n *\n *   track      a single 1px hairline bar dividing 100% across\n *              segments; each segment expands or shrinks with\n *              flex-grow\n *   dividers   1px hairline divider pins with centered grab notches;\n *              dragging redistributes adjacent allocations\n *              with zero layout thrash\n *   locking    padlock toggles freeze individual segments; locked\n *              segments stay firm while others adjust\n *   numbers    percentage readouts roll in place via NumberRoll\n *              upon release; DOM text updates immediately while\n *              dragging\n *\n * Dividers are accessible separators with ArrowLeft/Right keys.\n * ───────────────────────────────────────────────────────── */\n\nimport * as React from 'react';\nimport { Lock, Unlock } from 'lucide-react';\nimport { NumberRoll } from './number-roll';\nimport { INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';\nimport { normalizeRatios, type RatioSegment, useRatioBalancer } from './use-ratio-balancer';\n\nexport type { RatioSegment };\n\nexport interface RatioBalancerProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {\n  label?: string;\n  segments?: RatioSegment[];\n  defaultSegments?: RatioSegment[];\n  minRatio?: number;\n  disabled?: boolean;\n  onValueChange?: (ratios: number[]) => void;\n  onValueCommit?: (ratios: number[]) => void;\n  onLockToggle?: (segmentId: string, locked: boolean) => void;\n}\n\nconst DEFAULT_SEGMENTS: RatioSegment[] = [\n  { id: 'inference', label: 'Inference', ratio: 50 },\n  { id: 'training', label: 'Training', ratio: 30 },\n  { id: 'cache', label: 'Cache & KV', ratio: 20 },\n];\n\nconst COLORS = [\n  'bg-primary/15 text-primary border-primary/25',\n  'bg-foreground/5 text-foreground border-foreground/15',\n  'bg-muted-foreground/10 text-muted-foreground border-muted-foreground/20',\n  'bg-accent text-accent-foreground border-border',\n  'bg-primary/5 text-foreground border-primary/20',\n];\n\nexport function RatioBalancer({\n  label,\n  segments: controlledSegments,\n  defaultSegments = DEFAULT_SEGMENTS,\n  minRatio = 5,\n  disabled = false,\n  onValueChange,\n  onValueCommit,\n  onLockToggle,\n  className = '',\n  ...props\n}: RatioBalancerProps) {\n  const reduced = useInteractionReducedMotion();\n  const [internalSegments, setInternalSegments] = React.useState<RatioSegment[]>(defaultSegments);\n  const segments = controlledSegments ?? internalSegments;\n\n  const currentRatios = React.useMemo(() => normalizeRatios(segments.map((s) => s.ratio)), [segments]);\n\n  const handleChange = React.useCallback(\n    (nextRatios: number[]) => {\n      onValueChange?.(nextRatios);\n    },\n    [onValueChange]\n  );\n\n  const handleCommit = React.useCallback(\n    (nextRatios: number[]) => {\n      if (!controlledSegments) {\n        setInternalSegments((prev) =>\n          prev.map((seg, i) => ({\n            ...seg,\n            ratio: nextRatios[i] ?? seg.ratio,\n          }))\n        );\n      }\n      onValueCommit?.(nextRatios);\n    },\n    [controlledSegments, onValueCommit]\n  );\n\n  const balancer = useRatioBalancer({\n    segments,\n    minRatio,\n    disabled,\n    reduced,\n    onChange: handleChange,\n    onCommit: handleCommit,\n  });\n\n  const toggleLock = (segmentId: string) => {\n    if (disabled) return;\n    const target = segments.find((s) => s.id === segmentId);\n    const nextLocked = !(target?.locked ?? false);\n\n    if (!controlledSegments) {\n      setInternalSegments((prev) =>\n        prev.map((s) => (s.id === segmentId ? { ...s, locked: nextLocked } : s))\n      );\n    }\n    onLockToggle?.(segmentId, nextLocked);\n  };\n\n  return (\n    <div\n      {...props}\n      role=\"region\"\n      aria-label={label ?? 'Resource ratio balancer'}\n      className={`relative flex w-full max-w-xl select-none flex-col text-foreground ${className}`}\n    >\n      {/* Label and Total */}\n      {label && (\n        <div className=\"mb-3 flex items-center justify-between text-[12px] font-medium text-muted-foreground\">\n          <span>{label}</span>\n          <span className=\"font-mono text-[11px] tabular-nums text-muted-foreground/80\">100% TOTAL</span>\n        </div>\n      )}\n\n      {/* Main Bar Track */}\n      <div\n        ref={balancer.trackRef}\n        className=\"relative flex h-14 w-full overflow-hidden rounded-[14px] border border-border bg-surface p-1 shadow-[inset_0_0_0_1px_var(--border)]\"\n      >\n        {segments.map((seg, i) => {\n          const colorClass = COLORS[i % COLORS.length];\n          const isLast = i === segments.length - 1;\n          const ratio = currentRatios[i] ?? seg.ratio;\n\n          return (\n            <React.Fragment key={seg.id}>\n              {/* Segment Cell */}\n              <div\n                ref={(el) => {\n                  balancer.segmentRefs.current[i] = el;\n                }}\n                className={`relative flex min-w-[38px] flex-col justify-between overflow-hidden rounded-[10px] border px-2.5 py-1.5 transition-[flex-grow] will-change-[flex-grow] ${colorClass}`}\n                style={{ flexGrow: ratio }}\n              >\n                <div className=\"flex items-center justify-between gap-1\">\n                  <span className=\"truncate text-[11px] font-medium tracking-tight\">{seg.label}</span>\n                  <button\n                    type=\"button\"\n                    onClick={() => toggleLock(seg.id)}\n                    aria-label={`${seg.locked ? 'Unlock' : 'Lock'} ${seg.label}`}\n                    aria-pressed={seg.locked}\n                    className=\"cursor-pointer text-muted-foreground/80 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n                  >\n                    {seg.locked ? <Lock className=\"size-3 text-primary\" /> : <Unlock className=\"size-3 opacity-40 hover:opacity-100\" />}\n                  </button>\n                </div>\n\n                <div className=\"flex items-baseline justify-between\">\n                  <span\n                    ref={(el) => {\n                      balancer.readoutRefs.current[i] = el;\n                    }}\n                    style={{ display: balancer.activeDivider !== null ? 'inline' : 'none' }}\n                    className=\"font-mono text-[13px] font-medium tabular-nums\"\n                  >\n                    {Math.round(ratio)}%\n                  </span>\n                  <span\n                    style={{ display: balancer.activeDivider !== null ? 'none' : 'inline' }}\n                    className=\"font-mono text-[13px] font-medium tabular-nums\"\n                  >\n                    <NumberRoll value={Math.round(ratio)} suffix=\"%\" duration={380} />\n                  </span>\n                </div>\n              </div>\n\n              {/* Divider Grip */}\n              {!isLast && (\n                <div className=\"relative z-10 flex w-2 shrink-0 items-center justify-center\">\n                  <button\n                    ref={(el) => {\n                      balancer.dividerRefs.current[i] = el;\n                    }}\n                    {...balancer.getDividerProps(i)}\n                    type=\"button\"\n                    role=\"separator\"\n                    aria-orientation=\"vertical\"\n                    aria-label={`Adjust split between ${seg.label} and ${segments[i + 1]?.label}`}\n                    className={`group absolute -inset-y-1 -inset-x-1.5 flex cursor-ew-resize items-center justify-center touch-none select-none rounded ${INTERACTION_FOCUS}`}\n                  >\n                    <span className=\"h-6 w-0.5 rounded-full bg-border transition-colors group-hover:bg-primary group-focus-visible:bg-primary\" />\n                  </button>\n                </div>\n              )}\n            </React.Fragment>\n          );\n        })}\n      </div>\n\n      {/* Legend & Lock Status */}\n      <div className=\"mt-3 flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground\">\n        <span className=\"flex items-center gap-1.5\">\n          <span className=\"size-1.5 rounded-full bg-primary\" />\n          <span>Drag dividers to reallocate. Click lock to pin segment.</span>\n        </span>\n        <div className=\"flex items-center gap-3\">\n          {segments.map((seg) => (\n            <span key={seg.id} className=\"flex items-center gap-1 font-mono text-[10px] tabular-nums\">\n              <span>{seg.label}:</span>\n              <span className=\"font-semibold text-foreground\">{Math.round(currentRatios[segments.indexOf(seg)] ?? seg.ratio)}%</span>\n            </span>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n"},{"path":"registry/use-ratio-balancer.ts","type":"registry:component","content":"// Hairline UI · ratio-balancer · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { EASE_OUT } from './ui-interaction-motion';\n\nexport interface RatioSegment {\n  id: string;\n  label: string;\n  ratio: number;\n  color?: string;\n  locked?: boolean;\n}\n\ntype Options = {\n  segments: RatioSegment[];\n  minRatio?: number;\n  disabled: boolean;\n  reduced: boolean;\n  onChange?: (ratios: number[]) => void;\n  onCommit?: (ratios: number[]) => void;\n};\n\n/** Normalizes ratios to sum cleanly to 100. */\nexport function normalizeRatios(values: number[]): number[] {\n  const sum = values.reduce((a, b) => a + b, 0);\n  if (sum === 0) {\n    const even = 100 / values.length;\n    return values.map(() => Math.round(even));\n  }\n  let acc = 0;\n  const result = values.map((v, i) => {\n    if (i === values.length - 1) return Math.max(0, 100 - acc);\n    const rounded = Math.round((v / sum) * 100);\n    acc += rounded;\n    return rounded;\n  });\n  return result;\n}\n\nexport function useRatioBalancer(options: Options) {\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const segmentRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const readoutRefs = React.useRef<(HTMLSpanElement | null)[]>([]);\n  const dividerRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  const optionsRef = React.useRef(options);\n  optionsRef.current = options;\n\n  const [activeDivider, setActiveDivider] = React.useState<number | null>(null);\n\n  const gesture = React.useRef<{\n    id: number;\n    dividerIndex: number;\n    startRatios: number[];\n    currentRatios: number[];\n    trackWidth: number;\n    trackLeft: number;\n  } | null>(null);\n\n  const paint = React.useCallback(\n    (ratios: number[], animate: boolean) => {\n      const o = optionsRef.current;\n      ratios.forEach((ratio, i) => {\n        const seg = segmentRefs.current[i];\n        if (seg) {\n          seg.style.transition = animate && !o.reduced ? `flex-grow 360ms ${EASE_OUT}` : 'none';\n          seg.style.flexGrow = String(ratio);\n        }\n        const readout = readoutRefs.current[i];\n        if (readout) {\n          readout.textContent = `${Math.round(ratio)}%`;\n        }\n      });\n\n      // Update divider aria attributes\n      let accumulated = 0;\n      ratios.slice(0, -1).forEach((ratio, i) => {\n        accumulated += ratio;\n        const div = dividerRefs.current[i];\n        if (div) {\n          div.setAttribute('aria-valuenow', String(Math.round(accumulated)));\n        }\n      });\n    },\n    []\n  );\n\n  const commit = React.useCallback(\n    (next: number[], animate = true) => {\n      const clean = normalizeRatios(next);\n      paint(clean, animate);\n      optionsRef.current.onChange?.(clean);\n      optionsRef.current.onCommit?.(clean);\n    },\n    [paint]\n  );\n\n  const finish = React.useCallback(\n    (cancelled: boolean) => {\n      const g = gesture.current;\n      if (!g) return;\n      gesture.current = null;\n      setActiveDivider(null);\n\n      if (cancelled) {\n        paint(g.startRatios, true);\n        optionsRef.current.onChange?.(g.startRatios);\n      } else {\n        commit(g.currentRatios, false);\n      }\n    },\n    [commit, paint]\n  );\n\n  React.useLayoutEffect(() => {\n    if (!gesture.current) {\n      const currentRatios = options.segments.map((s) => s.ratio);\n      paint(currentRatios, true);\n    }\n  }, [options.segments, options.reduced, paint]);\n\n  React.useEffect(() => {\n    const interrupted = () => finish(true);\n    const hidden = () => {\n      if (document.hidden) interrupted();\n    };\n    window.addEventListener('blur', interrupted);\n    document.addEventListener('visibilitychange', hidden);\n    return () => {\n      window.removeEventListener('blur', interrupted);\n      document.removeEventListener('visibilitychange', hidden);\n    };\n  }, [finish]);\n\n  const startDrag = React.useCallback(\n    (dividerIndex: number, event: React.PointerEvent<HTMLButtonElement>) => {\n      if (event.button !== 0 || !event.isPrimary || optionsRef.current.disabled) return;\n      const track = trackRef.current;\n      if (!track) return;\n\n      const rect = track.getBoundingClientRect();\n      event.currentTarget.setPointerCapture(event.pointerId);\n      event.currentTarget.focus({ preventScroll: true });\n\n      const currentRatios = optionsRef.current.segments.map((s) => s.ratio);\n\n      gesture.current = {\n        id: event.pointerId,\n        dividerIndex,\n        startRatios: [...currentRatios],\n        currentRatios: [...currentRatios],\n        trackWidth: Math.max(1, rect.width),\n        trackLeft: rect.left,\n      };\n\n      setActiveDivider(dividerIndex);\n      paint(currentRatios, false);\n    },\n    [paint]\n  );\n\n  const onPointerMove = React.useCallback(\n    (event: React.PointerEvent<HTMLButtonElement>) => {\n      const g = gesture.current;\n      const o = optionsRef.current;\n      if (!g || g.id !== event.pointerId) return;\n\n      const idx = g.dividerIndex;\n      const minRatio = o.minRatio ?? 5;\n      const segs = o.segments;\n\n      // If both adjacent are locked, cannot adjust\n      if (segs[idx]?.locked && segs[idx + 1]?.locked) return;\n\n      const clientX = event.clientX;\n      const pointerRatio = ((clientX - g.trackLeft) / g.trackWidth) * 100;\n\n      // Accumulated percentage of segments before idx\n      let prefixSum = 0;\n      for (let i = 0; i < idx; i++) prefixSum += g.startRatios[i];\n\n      const combinedPair = g.startRatios[idx] + g.startRatios[idx + 1];\n      let newLeft = pointerRatio - prefixSum;\n\n      // Clamp within min bounds\n      newLeft = Math.max(minRatio, Math.min(combinedPair - minRatio, newLeft));\n      const newRight = combinedPair - newLeft;\n\n      const next = [...g.currentRatios];\n      next[idx] = Math.round(newLeft);\n      next[idx + 1] = Math.round(newRight);\n\n      if (next[idx] === g.currentRatios[idx]) return;\n\n      g.currentRatios = next;\n      paint(next, false);\n      o.onChange?.(next);\n    },\n    [paint]\n  );\n\n  const makeKeyHandler = React.useCallback(\n    (dividerIndex: number) => (event: React.KeyboardEvent<HTMLButtonElement>) => {\n      if (event.key === 'Escape') {\n        event.preventDefault();\n        finish(true);\n        return;\n      }\n\n      if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;\n      event.preventDefault();\n\n      const o = optionsRef.current;\n      const delta = (event.shiftKey ? 5 : 1) * (event.key === 'ArrowRight' ? 1 : -1);\n      const minRatio = o.minRatio ?? 5;\n      const segs = o.segments;\n\n      if (segs[dividerIndex]?.locked && segs[dividerIndex + 1]?.locked) return;\n\n      const ratios = segs.map((s) => s.ratio);\n      const combined = ratios[dividerIndex] + ratios[dividerIndex + 1];\n\n      let newLeft = ratios[dividerIndex] + delta;\n      newLeft = Math.max(minRatio, Math.min(combined - minRatio, newLeft));\n\n      const next = [...ratios];\n      next[dividerIndex] = newLeft;\n      next[dividerIndex + 1] = combined - newLeft;\n\n      commit(next);\n    },\n    [commit, finish]\n  );\n\n  return {\n    trackRef,\n    segmentRefs,\n    readoutRefs,\n    dividerRefs,\n    activeDivider,\n    commit,\n    getDividerProps: (idx: number) => ({\n      onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => startDrag(idx, e),\n      onPointerMove,\n      onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(false);\n      },\n      onPointerCancel: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(true);\n      },\n      onLostPointerCapture: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(true);\n      },\n      onBlur: () => finish(true),\n      onKeyDown: makeKeyHandler(idx),\n    }),\n  };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · ratio-balancer · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/ratio-balancer. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}