Ratio balancer

A multi-segment allocation track. Drag dividers to rebalance constrained percentages with lockable pins.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/ratio-balancer.json

Overview

Distribute a shared 100% budget among multiple competing categories. Thin hairline dividers glide between segments, numbers roll to settled totals, and padlock toggles freeze specific portions so adjustments only redistribute across unlocked peers. Built with pure flex-grow transitions and zero animation libraries.

States

rebalance
Dragging a divider updates adjacent segment flex-grow styles and DOM text nodes directly.
lock
Clicking a padlock freezes a segment; adjacent divider drags respect locked segments without altering them.
commit
Releasing the pointer normalizes all ratios to 100% and rolls final percentage digits via NumberRoll.
keyboard
Dividers accept focus; Left and Right arrow keys nudge allocations by 1% (or 5% with Shift).

Usage

usage.tsx
import { RatioBalancer } from "@/components/ratio-balancer";<RatioBalancer  label="Compute allocation"  defaultSegments={[    { id: "eval", label: "Evaluation", ratio: 45 },    { id: "batch", label: "Batch tasks", ratio: 35 },    { id: "dev", label: "Dev testing", ratio: 20 },  ]}  onValueCommit={setRatios}/>

Works with

Allocate resource quotas in cloud settings

Use RatioBalancer to configure server cluster allocation or team spend limits. It maintains total conservation (always 100%) while allowing users to lock specific baselines.

example.tsx
"use client";import { useState } from "react";import { RatioBalancer, type RatioSegment } from "@/components/ratio-balancer";export function QuotaManager() {  const [segments, setSegments] = useState<RatioSegment[]>([    { id: "gpu", label: "GPU Clusters", ratio: 60, locked: true },    { id: "cpu", label: "General CPU", ratio: 25 },    { id: "storage", label: "NVMe Cache", ratio: 15 },  ]);  return (    <div className="rounded-2xl border border-border bg-card p-6">      <RatioBalancer        label="Cluster resources"        segments={segments}        onValueCommit={(ratios) => {          setSegments(prev => prev.map((s, i) => ({ ...s, ratio: ratios[i] })));        }}        onLockToggle={(id, locked) => {          setSegments(prev => prev.map(s => s.id === id ? { ...s, locked } : s));        }}      />    </div>  );}

Props

PropTypeDescription
labelstringAccessible heading displayed above the allocation bar.
segments / defaultSegmentsRatioSegment[]Array of segment objects with id, label, ratio (summing to 100), and optional locked state.
minRationumberdefault 5Minimum allowed percentage for any segment during drag.
disabledbooleandefault falseDisables all divider dragging and padlock toggling.
onValueChange(ratios: number[]) => voidFired during divider movement with immediate ratio updates.
onValueCommit(ratios: number[]) => voidFired upon pointer release or keyboard adjustment with clean normalized integers.
onLockToggle(segmentId: string, locked: boolean) => voidCallback when a segment padlock is toggled.

Accessibility and motion

  • Dividers act as semantic separators with vertical orientation and aria-valuenow.
  • Segments maintain pure DOM styles during drag; React state updates occur only on commit.
  • Reduced motion removes flex-grow easing transitions and rolls digits in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Number roll.

Source

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

Registry item: /r/ratio-balancer.json