Inputs

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.

Preview in your theme

Loading preview…

"use client"

import { Lock, LockOpen } from "lucide-react"
import * as React from "react"

import { cn } from "@/lib/utils"

/** How the budget left over by an edit is handed to the other rows. */
export type AllocationMode =
  /** Split in proportion to the other rows' current values — ratios survive. */
  | "proportional"
  /** Split evenly, so every other unlocked row ends up on the same value. */
  | "equal"
  /** Hand the whole remainder to the last unlocked row; every other row freezes. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/percent-allocator.json

Prompt

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^precision on 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. mode picks 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.

On This Page