Percent Allocator
Split a total across rows with largest-remainder rebalancing, per-row locks and min/max limits — the shares always add up to the target exactly.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/percent-allocator.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PercentAllocator": N rows share one total
(100% by default) and the rows must always add up to that total exactly.
Contract
- Controlled: items: { id, label, value, locked?, min?, max? }[] plus
onChange(items) — always the full array, with untouched rows returned by
reference so React can skip them.
- total = 100, precision = 0 (decimal places, clamped 0-6), unit = "%",
locale = "en-US", step = 10^-precision (keyboard step in display units),
mode = "proportional" | "equal" | "last" | "locked", lockable = false
(renders a per-row lock toggle that emits `locked` back through onChange),
disabled = false, totalLabel = "Total".
- Also export the pure core so servers and tests can use it:
reallocate(items, id, nextValue, options) and autoBalance(items, options),
both returning { ok: true, items } | { ok: false, reason }, reason being
"row-locked" | "no-free-row" | "infeasible" | "unknown-row". Never mutate the
input array.
Behavior
- Integer grid: convert every value, min, max and the total to integer units
(value * 10^precision) on entry and back on exit. All arithmetic happens in
units, so no float tail can ever leave the rows at 99.99999999. State the
invariant on that grid — sum(round(value * 10^p)) === round(total * 10^p) —
because at p = 6 a double cannot hold every decimal exactly anyway.
- One edit = "set row X to V, hand the rest to the absorbing set". The absorbing
set is chosen by mode: proportional = every other unlocked row, weighted by
its current value; equal = every other unlocked row, weight 1 (they end up
equal); last = only the last unlocked row (a designated remainder bucket, every
other row freezes); locked = nobody (manual mode: the total is allowed to drift
and the header offers Auto-balance).
- Rounding MUST be largest remainder (Hamilton), never per-row rounding: give
every slot its floor, then hand the leftover units one at a time to the biggest
fractional parts (ties by index, so it is deterministic). Naive rounding of
100/3 gives 33+33+33 = 99; this gives 34+33+33 = 100.
- min/max: distribute over [0, max-min] room starting from each row's min. Slots
that hit their cap drop out and the pass repeats, so limits and an exact total
coexist. Each pass either finishes or fills one slot, so it cannot spin; keep a
hard iteration guard anyway.
- Feasibility window: before writing, clamp the edited row to its own [min,max]
intersected with what the absorbing set can still give or take
(total - fixedSum - freeMax .. total - fixedSum - freeMin). Without it a drag
would request a split that does not exist and the total would drift.
- Refuse instead of guessing. Editing a locked row -> "row-locked". Every other
row locked, so nothing can absorb the change -> "no-free-row" (unlocking ONE
row is not enough — a single row has nothing to trade with). Row minimums that
add up to more than the total, or maximums that add up to less -> "infeasible",
reported as a persistent alert that also blocks edits. Each refusal renders a
sentence explaining which rule fired; locked rows stay focusable (aria-disabled,
never the native disabled attribute) so keyboard users get the message too.
- Header shows "sum / total" and, whenever they differ (imported data, manual
mode), a role="alert" block naming the gap plus an Auto-balance button that
spreads the whole target over the unlocked rows using their current values as
weights. Move focus to a surviving row before that alert unmounts, or focus
drops to <body>.
- Per row: a drag track (role="slider" thumb: aria-valuenow/min/max/valuetext,
arrows, PageUp/PageDown = 10 steps, Home/End = window ends) and a numeric
field. Use type="text" inputMode="decimal", not type="number": a controlled
number input swallows intermediate keystrokes. The field keeps a draft while
focused, commits on blur/Enter, reverts on Escape, and says so when the typed
value had to be capped.
- Dragging: pointer capture on the track; grabbing the thumb keeps the pointer's
offset (relative delta, so a 4px nudge moves 4px and never teleports), pressing
the bare track jumps to the press point. Bail out of the drag when
pointermove reports buttons === 0 — releasing outside the track never delivers
pointerup and browsers reuse pointerIds, so a later hover would keep dragging.
- Announce once, not per row: a single role="status" live region gets
"<row> <value>. N other rows rebalanced. Total X of Y." after each committed
change. The alert region is separate (role="alert").
Rendering & styling
- Semantic tokens only: bg-card / bg-muted / text-muted-foreground / border /
bg-primary + text-primary-foreground for the balance button, text-destructive
on bg-destructive/10 for refusals. Segment fills (stacked bar, row fill, legend
dot) use var(--chart-1..5) cycling — chart tokens are for fills only, never text.
- Stacked bar on top: one aria-hidden segment per row, widths in %, hairline
border-background dividers (skipped for zero-width rows so a 0% row cannot
paint a 1px sliver); the numbers live in the rows, not in the bar.
- Thumb and fill transition on left/width only while nothing is being dragged,
and the transition is dropped under motion-reduce; dragging always tracks the
finger exactly.
- cn() merges the consumer className; the root is a role="group" that spreads the
remaining props, forwardRef to the root div.
Customization levers
- Meaning of the total: total + unit turn it into hours, GB, seats or dollars —
nothing in the math assumes percent.
- Rounding grid: precision 0-6; the largest-remainder pass keeps the sum exact at
any grid, so 2 decimals over a 33.33 total is as safe as integers over 100.
- Rebalance policy: mode is the single knob (proportional / equal / last /
locked). A new policy = one entry in the weight function, nothing else.
- Guard rails: per-row min/max for "at least 10% cash", locked for "this one is
agreed", lockable to let users pin rows themselves.
- Density: drop the numeric field for a slider-only board, or drop the track for
a form-style number list — the pure core does not care which one drives it.
- Chrome: totalLabel, the stacked bar and the legend dots are independent blocks;
remove the bar for compact sidebars, keep the header for the invariant.Concepts
- Sum-to-total invariant — every accepted edit re-derives the other rows so the shares add up to the target exactly; the header is a live proof of it, and a mismatch can only come from incoming data (or manual mode), never from the control's own math.
- Largest remainder (Hamilton) — floors first, leftover units to the biggest fractional parts. Rounding each row on its own loses or invents a unit; this is why 100 split three ways reads 34 / 33 / 33 instead of 33 / 33 / 33.
- Integer unit grid — values are converted to
value × 10^precisionon entry, so the whole algorithm is integer arithmetic and floats never accumulate a 0.0000000001 tail. - Absorbing set — the rows allowed to move for this edit.
modepicks it: everyone unlocked (weighted or even), just the last row, or nobody. Locked rows are simply never in the set. - Feasibility window — a row can only move as far as the absorbing set can still give or take, so a drag can never request a split that does not exist.
- Refusal over silent clamp — impossible minimums, all-locked boards and locked rows produce a named reason instead of a plausible wrong number; the rows stay focusable so the explanation reaches keyboard and screen-reader users.