Dots

Percent Split Editor

Rows of people or destinations sharing one total in percent, amounts or shares, with a share bar and a remainder line.

  • AM
    Alex Morgan$960.00
  • JL
    Jordan Lee$840.00
  • SC
    Sam Carter$360.00
10% left to assign
"use client"

import * as React from "react"

PercentSplitEditor splits one total between people or destinations, such as rent between roommates or a dinner bill between friends. Each row has a number field, a share bar shows how the total is divided, and a remainder line says what is left to assign or how much is over. It only reports changes through onValueChange and never rebalances other rows, so your app decides how the rest moves. For a read-only breakdown of a total, use ShareBar instead.

Installation

pnpm dlx shadcn@latest add @dots/percent-split-editor

Usage

import {
  PercentSplitBar,
  PercentSplitEditor,
  PercentSplitList,
  PercentSplitRemainder,
  PercentSplitRow,
} from "@/components/ui/percent-split-editor"
const [items, setItems] = React.useState([
  { id: "alex", label: "Alex Morgan", value: 50 },
  { id: "jordan", label: "Jordan Lee", value: 50 },
])
 
<PercentSplitEditor
  aria-label="Rent split"
  items={items}
  onValueChange={(id, value) =>
    setItems((current) =>
      current.map((item) => (item.id === id ? { ...item, value } : item))
    )
  }
  total={2400}
>
  <PercentSplitBar />
  <PercentSplitList>
    {items.map((item) => (
      <PercentSplitRow key={item.id} id={item.id} />
    ))}
  </PercentSplitList>
  <PercentSplitRemainder />
</PercentSplitEditor>

Composition

Use the following composition to build a split editor:

PercentSplitEditor
├── PercentSplitBar
├── PercentSplitList
│   └── PercentSplitRow
└── PercentSplitRemainder

Read the totals anywhere inside the editor with usePercentSplitEditor(), for example to turn off a save button until the split adds up.

Features

  • Three modes: percent of 100%, amount of a money total, and shares as weights
  • A React Aria number field per row with locale-aware formatting and optional minus and plus buttons
  • The computed counterpart on each row: the amount in percent and shares mode, the percent in amount mode
  • One share bar with a segment per row and an empty track for what is left
  • A remainder line that turns destructive until the split balances, announced once the value settles
  • Rows that can be turned off, with a slot for an include checkbox
  • usePercentSplitEditor() for the sum, remainder and balanced state

Percent

The default mode, shown in the demo at the top of this page. Values are shares of 100%. Pass total to show each person's amount under their name. Add stepper to show minus and plus buttons around each field.

function SaveButton() {
  const { isBalanced } = usePercentSplitEditor()
  return <Button isDisabled={!isBalanced}>Save split</Button>
}

Amount

Set mode="amount" and a total to split money directly. Each row shows its percent of the total, and the remainder line shows what is left or over in the currency. Use description for a quiet note, such as who paid.

  • Alex MorganPaid the bill, 44.04%
  • Jordan Lee31.09%
  • Sam Carter31.09%
$6.00 over
"use client"

import * as React from "react"

Shares

Set mode="shares" for weights such as 2 for a larger room. Nothing has to add up, so the split is always balanced. With a total, rows show their amount and the remainder line shows the price per share.

  • Room A$90.00
  • Room B$45.00
  • Room C$45.00
4 shares · $45.00 per share
"use client"

import * as React from "react"

Optional rows

Set isDisabled on an item to leave it out, and put a checkbox in the row's action slot. Pass a function to PercentSplitRemainder to change the wording.

  • Alex Morgan
  • Jordan Lee
  • Sam Carter
Split between 2 people
"use client"

import * as React from "react"
<PercentSplitRemainder>
  {(summary, format) =>
    summary.isBalanced
      ? `Split between ${included} people`
      : `${format.percent(Math.abs(summary.remainder))} left`
  }
</PercentSplitRemainder>

Accessibility

The editor renders a role="group", the list is a <ul> with one <li> per row, and each row's field is a React Aria NumberField with arrow key stepping and localized minus and plus button names.

  • Name the editor with aria-label or aria-labelledby, for example "Rent split".
  • Each field is named from the row, such as "Alex Morgan share in percent", "Jordan Lee amount" or "Room A shares". Keep label short and unique.
  • The bar is a single role="img" whose label lists every share, for example "Alex Morgan 40%, Jordan Lee 35%, Sam Carter 15%, 10% unassigned". Change the last word with unassignedLabel.
  • The remainder is announced through a polite status after the value stays still for announceDelay milliseconds, not on every keystroke, and not on mount.
  • The remainder states the problem in words ("10% left to assign", "$6.00 over"), so the destructive color is never the only signal.
  • Give an action checkbox its own aria-label, such as "Include Sam Carter".

API Reference

PercentSplitEditor

The root. Renders a <div role="group"> and accepts its props except onChange.

PropTypeDefaultDescription
itemsPercentSplitItem[]-The rows: id, label, value and optional isDisabled.
onValueChange(id: string, value: number) => void-Called with the row id and its new value. Rebalancing the rest is up to you.
mode"percent" | "amount" | "shares""percent"What the values mean.
totalnumber-The money being split. Required in amount mode. In other modes it adds amounts.
currencystring"USD"ISO 4217 code for amounts.
localestring"en-US"The locale used for formatting.
classNamestring-Additional classes to apply to the root.

PercentSplitBar

One bar with a segment per row with a value above zero, and an empty track for what is left.

PropTypeDefaultDescription
unassignedLabelstring"unassigned"Read after the shares when part of the target is left.
classNamestring-Additional classes to apply to the bar.

PercentSplitList

The list of rows. Renders a <ul role="list"> and accepts all of its props.

PropTypeDefaultDescription
classNamestring-Additional classes to apply to the list.

PercentSplitRow

One row, rendered as an <li>: name, computed counterpart and a number field. Renders nothing for an unknown id.

PropTypeDefaultDescription
idstring-The id of an item in the editor's items.
leadingReactNode-An avatar or logo before the name.
descriptionReactNode-Quiet text before the computed counterpart, e.g. "Paid the bill".
stepperbooleanfalseShow minus and plus buttons around the field.
stepnumber1, or 0.01 in amount modeThe step for the buttons and arrow keys.
actionReactNode-A control after the field, e.g. an include checkbox.
classNamestring-Additional classes to apply to the row.

PercentSplitRemainder

What is left to assign or how much is over, in destructive color until the split balances.

PropTypeDefaultDescription
children(summary: PercentSplitSummary, format: { percent: (n: number) => string; money: (n: number) => string }) => ReactNode-Replaces the default wording.
announcement(summary: PercentSplitSummary) => string-What screen readers hear. Defaults to the visible text.
announceDelaynumber800Milliseconds the value must stay still before it is announced.
classNamestring-Additional classes to apply to the remainder.

usePercentSplitEditor

Returns the totals of the nearest PercentSplitEditor. Throws outside one.

FieldTypeDescription
mode"percent" | "amount" | "shares"The editor's mode.
sumnumberThe sum of all values.
targetnumber | null100 in percent mode, total in amount mode, null in shares mode.
remaindernumbertarget − sum, rounded to hundredths. Positive is left to assign.
state"under" | "balanced" | "over"Where the split stands.
isBalancedbooleantrue when state is "balanced".

PercentSplitItem

type PercentSplitItem = {
  id: string
  label: string
  value: number
  isDisabled?: boolean
}