Actions and inputs
Ratio balancer
A bar split into segments that always add up to 100%, where dragging a divider moves share between its two neighbours and the percentages roll to the new numbers when you let go.
Live preview
Loading preview
A "Monthly budget" card with three segments: Rent 45%, Savings 30%, Fun 25%, minRatio 10. Replay resets the split, drags the first divider to give Rent 55%, then presses Arrow Left twice on the second divider.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add ratio-balancerUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { RatioBalancer, type RatioSegment } from './ratio-balancer';
const BUDGET: RatioSegment[] = [
{ id: 'rent', label: 'Rent', ratio: 45 },
{ id: 'savings', label: 'Savings', ratio: 30 },
{ id: 'fun', label: 'Fun', ratio: 25 },
];
/** Replay: drag the first divider until Rent has 55%, then press Arrow Left twice on the second divider. */
function runScript(root: HTMLElement, later: (ms: number, run: () => void) => void) {
const dividers = () => root.querySelectorAll<HTMLElement>('[data-slot="ratio-divider"]');
const pointer = (el: HTMLElement | undefined, type: string, x: number) =>
el?.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, button: 0, clientX: x }));
const key = (el: HTMLElement | undefined) =>
el?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
later(300, () => {
const first = dividers()[0];
if (!first) return;
const segments = [...root.querySelectorAll('[data-slot="ratio-segment"]')];
const span = segments.reduce((sum, el) => sum + el.getBoundingClientRect().width, 0);
const x = first.getBoundingClientRect().left + 6;
pointer(first, 'pointerdown', x);
const steps = 12;
for (let i = 1; i <= steps; i++)
later(i * 40, () => pointer(first, 'pointermove', x + (span * 0.1 * i) / steps));
later(steps * 40 + 200, () => pointer(first, 'pointerup', x + span * 0.1));
});
later(1700, () => key(dividers()[1]));
later(2300, () => key(dividers()[1]));
}
export default function RatioBalancerDemo({ replay = 0 }: { replay?: number }) {
const area = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (replay === 0 || !area.current) return;
const timers: number[] = [];
const later = (ms: number, run: () => void) => timers.push(window.setTimeout(run, ms));
runScript(area.current, later);
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div ref={area} className="w-full max-w-[480px]">
{/* Replay mounts the balancer again, so the split starts from the same place. */}
<RatioBalancer key={replay} label="Monthly budget" defaultSegments={BUDGET} minRatio={10} />
</div>
);
}
API
Parts: root (region), label row (label, "Total 100%"), track, segment (label, percentage), divider (separator with grip line), legend (help line, each segment with its percentage).
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | undefined | Visible label; also names the region. Without it the region is named "Ratio balancer". |
| segments | { id: string; label: string; ratio: number }[] | undefined | Controlled segments; ratios are normalized to 100. |
| defaultSegments | { id: string; label: string; ratio: number }[] | three segments, 50 / 30 / 20 | Starting segments when uncontrolled. |
| minRatio | number | 5 | Smallest share in percent any segment can have. |
| disabled | boolean | false | Turns all dividers off. |
| help | string | "Drag a divider to change the split." | Help line in the legend (Figma text property Help). |
| Event | Description |
|---|---|
| valueChange | Fires with the ratios on every change while dragging. |
| valueCommit | Fires with the ratios when a drag ends or after a key press. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| RB1 | hover or focus a divider | divider grip line | background-color | border-primary → fg-brand-primary | duration-xs (160)ease-out | Swaps at once |
| RB2 | drag a divider | two neighbour segments | flex-grow (follows the pointer 1:1 from the press point, whole percents, clamped to minRatio) | start ratios → pointer ratios | 0ease-linear | Resizes at once |
| RB3 | drag a divider | segment percentages | text (plain numbers update while dragging) | old percent → new percent | 0ease-linear | Swaps at once |
| RB4 | drag ends, or a key press | segment percentages | number roll | old percent → new percent | duration-xl (380)ease-out | Swaps at once |
| RB5 | Arrow Left or Right (Shift: 5), Home or End, or segments set from outside | two neighbour segments | flex-grow | old ratios → new ratios | duration-xl (380)ease-out | Resizes at once |
| RB6 | Escape, blur, pointer cancel, window blur or a hidden tab during a drag | two neighbour segments | flex-grow (back to where the drag started) | drag ratios → start ratios | duration-xl (380)ease-out | Resizes at once |
Accessibility
- The root is a region named by the label; each divider is a focusable separator (aria-orientation vertical) named "Change the split between <left> and <right>".
- Each divider also exposes aria-valuenow (left segment percent), aria-valuemin (minRatio), aria-valuemax (pair total minus minRatio) and aria-valuetext ("Inference 50%, Training 30%").
- Keyboard: Arrow Left and Right move 1%, Shift with an arrow moves 5%, Home gives the left segment its minimum and End its maximum, Escape during a drag puts the split back.
- The legend repeats every segment with its percentage as plain text; percentages inside the bar are text, not images.
- Dividers are 12px wide with an invisible hit area of 14px each side (40px in all) and the full bar height (56px); touch-action none so dragging does not scroll the page.
- Reduced motion: segments resize and numbers swap at once.