- Accordion
- Action Bar
- Action Toast
- Alert Banner
- Alert Dialog
- Alert
- Alphabet Index
- Amount Field
- Aspect Ratio
- Attachment
- Availability List
- Avatar
- Badge
- Bank Card
- Breadcrumb
- Bubble
- Button Group
- Button
- Calendar
- Camera Frame
- Card Design Picker
- Card Details
- Card Peek
- Card Stack
- Card
- Carousel
- Chart
- Chat Thread
- Checkbox
- Chip Group
- Choice Tile
- Code Block
- Code Dots
- Collapsible
- Combobox
- Command
- Context Menu
- Copy Field
- Countdown
- Country Select
- Data Table
- Date Field
- Date Picker
- Date Range Picker
- Dialog
- Direction
- Document Paper
- Drawer
- Dropdown Menu
- Empty
- Entity Avatar
- Field
- File Trigger
- File Upload Item
- Hover Card
- Input Group
- Input OTP
- Input
- Item
- Kbd
- Key Value
- Keypad
- Label
- Live Region
- Marker
- Message Scroller
- Message
- Money Amount
- Native Select
- Number Field
- Pagination
- Percent Split Editor
- Phone Field
- Popover
- Progress
- QR Code
- Questionnaire
- Radio Group
- Range Histogram
- Rating
- Resizable
- Scroll Area
- Search Field
- Select
- Separator
- Share Bar
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner
- Sortable List
- Spinner
- Status Timeline
- Step Header
- Steps List
- Swipe Row
- Switch
- Table
- Tabs
- Text Field
- Textarea
- Tier Ladder
- Toggle Group
- Toggle
- Token Search Field
- Tooltip
- Trace
- Transaction Row
- Usage Meter
- Alex Morgan$960.00$960.00AM
- Jordan Lee$840.00$840.00JL
- Sam Carter$360.00$360.00SC
"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
└── PercentSplitRemainderRead the totals anywhere inside the editor with usePercentSplitEditor(), for example to turn off a save button until the split adds up.
Features#
- Three modes:
percentof 100%,amountof a money total, andsharesas 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%
"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$90.00
- Room B$45.00$45.00
- Room C$45.00$45.00
"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
"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-labeloraria-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
labelshort 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 withunassignedLabel. - The remainder is announced through a polite status after the value stays still for
announceDelaymilliseconds, 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
actioncheckbox its ownaria-label, such as "Include Sam Carter".
API Reference#
PercentSplitEditor#
The root. Renders a <div role="group"> and accepts its props except onChange.
| Prop | Type | Default | Description |
|---|---|---|---|
items | PercentSplitItem[] | - | 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. |
total | number | - | The money being split. Required in amount mode. In other modes it adds amounts. |
currency | string | "USD" | ISO 4217 code for amounts. |
locale | string | "en-US" | The locale used for formatting. |
className | string | - | 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.
| Prop | Type | Default | Description |
|---|---|---|---|
unassignedLabel | string | "unassigned" | Read after the shares when part of the target is left. |
className | string | - | Additional classes to apply to the bar. |
PercentSplitList#
The list of rows. Renders a <ul role="list"> and accepts all of its props.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | 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.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | - | The id of an item in the editor's items. |
leading | ReactNode | - | An avatar or logo before the name. |
description | ReactNode | - | Quiet text before the computed counterpart, e.g. "Paid the bill". |
stepper | boolean | false | Show minus and plus buttons around the field. |
step | number | 1, or 0.01 in amount mode | The step for the buttons and arrow keys. |
action | ReactNode | - | A control after the field, e.g. an include checkbox. |
className | string | - | 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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
announceDelay | number | 800 | Milliseconds the value must stay still before it is announced. |
className | string | - | Additional classes to apply to the remainder. |
usePercentSplitEditor#
Returns the totals of the nearest PercentSplitEditor. Throws outside one.
| Field | Type | Description |
|---|---|---|
mode | "percent" | "amount" | "shares" | The editor's mode. |
sum | number | The sum of all values. |
target | number | null | 100 in percent mode, total in amount mode, null in shares mode. |
remainder | number | target − sum, rounded to hundredths. Positive is left to assign. |
state | "under" | "balanced" | "over" | Where the split stands. |
isBalanced | boolean | true when state is "balanced". |
PercentSplitItem#
type PercentSplitItem = {
id: string
label: string
value: number
isDisabled?: boolean
}