Lunia DS

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.

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-balancer

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
labelstringundefinedVisible label; also names the region. Without it the region is named "Ratio balancer".
segments{ id: string; label: string; ratio: number }[]undefinedControlled segments; ratios are normalized to 100.
defaultSegments{ id: string; label: string; ratio: number }[]three segments, 50 / 30 / 20Starting segments when uncontrolled.
minRationumber5Smallest share in percent any segment can have.
disabledbooleanfalseTurns all dividers off.
helpstring"Drag a divider to change the split."Help line in the legend (Figma text property Help).
EventDescription
valueChangeFires with the ratios on every change while dragging.
valueCommitFires 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
RB1hover or focus a dividerdivider grip linebackground-colorborder-primary → fg-brand-primaryduration-xs (160)ease-outSwaps at once
RB2drag a dividertwo neighbour segmentsflex-grow (follows the pointer 1:1 from the press point, whole percents, clamped to minRatio)start ratios → pointer ratios0ease-linearResizes at once
RB3drag a dividersegment percentagestext (plain numbers update while dragging)old percent → new percent0ease-linearSwaps at once
RB4drag ends, or a key presssegment percentagesnumber rollold percent → new percentduration-xl (380)ease-outSwaps at once
RB5Arrow Left or Right (Shift: 5), Home or End, or segments set from outsidetwo neighbour segmentsflex-growold ratios → new ratiosduration-xl (380)ease-outResizes at once
RB6Escape, blur, pointer cancel, window blur or a hidden tab during a dragtwo neighbour segmentsflex-grow (back to where the drag started)drag ratios → start ratiosduration-xl (380)ease-outResizes 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.