Repeating rows of fields inside a Form, such as invoice lines or team members. Add, remove, move up or down and insert are all buttons, so the keyboard does everything; focus lands somewhere sensible and a polite message says what changed. The contents of the rows and any totals are yours.
import { FieldArray, Form, FormNumberField, FormSubmitButton, FormTextField, useFormValues } from "@rdloom/react";
type Line = { description: string; qty: number | null; price: number | null };
type Values = { lines: Line[] };
// The total is this app's own arithmetic: the library only hands over the values.
function Total() {
const total = useFormValues<Values, number>((v) => v.lines.reduce((sum, line) => sum + (line.qty ?? 0) * (line.price ?? 0), 0));
return (
<p className="text-sm font-medium text-[var(--rd-color-text-default)]">
Total: {new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(total)}
</p>
);
}
export default function FieldArrayInvoiceLinesExample() {
return (
<Form<Values>
className="w-full max-w-3xl"
defaultValues={{ lines: [{ description: "Design work", qty: 10, price: 80 }] }}
onSubmit={() => {}}
>
<FieldArray
name="lines"
label="Invoice lines"
itemLabel="Line"
addLabel="Add line"
emptyText="No lines yet. Add the first one."
defaultRow={{ description: "", qty: 1, price: null }}
minRows={1}
maxRows={8}
allowInsert
>
{(row) => (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<FormTextField name={row.name("description")} label="Description" isRequired />
</div>
<FormNumberField name={row.name("qty")} label="Qty" minValue={1} isRequired />
<FormNumberField
name={row.name("price")}
label="Price"
minValue={0}
formatOptions={{ style: "currency", currency: "USD" }}
isRequired
/>
</div>
)}
</FieldArray>
<Total />
<FormSubmitButton>Save invoice</FormSubmitButton>
</Form>
);
}Installation
npx rdloom add field-arrayCopies the source into src/components/rdloom. Edit it freely: rdloom upgrade merges later versions into your changes. It needs react-aria-components; add --install to install them.
Use another registry client
npx shadcn@latest add https://rdloom.vimalbhatt.com/r/field-array.jsonWorks, but without upgrade tracking.
Usage
import { FieldArray, Form, FormNumberField, FormSubmitButton, FormTextField, useFormValues } from "@rdloom/react";
<p className="text-sm font-medium text-[var(--rd-color-text-default)]">
Total: {new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(total)}
</p>
);
}
export default function FieldArrayInvoiceLinesExample() {
return (
<Form<Values>
className="w-full max-w-3xl"
defaultValues={{ lines: [{ description: "Design work", qty: 10, price: 80 }] }}
onSubmit={() => {}}
>
<FieldArray
name="lines"
label="Invoice lines"
itemLabel="Line"
addLabel="Add line"
emptyText="No lines yet. Add the first one."
defaultRow={{ description: "", qty: 1, price: null }}
minRows={1}
maxRows={8}
allowInsert
>
{(row) => (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<FormTextField name={row.name("description")} label="Description" isRequired />
</div>
<FormNumberField name={row.name("qty")} label="Qty" minValue={1} isRequired />
<FormNumberField
name={row.name("price")}
label="Price"
minValue={0}
formatOptions={{ style: "currency", currency: "USD" }}
isRequired
/>
</div>
)}
</FieldArray>
<Total />
<FormSubmitButton>Save invoice</FormSubmitButton>
</Form>API Reference
Defined by the spec. Components also accept the props of the React Aria component they wrap.
| Prop | Type | Default |
|---|---|---|
namerequiredWhere the rows live in the Form values, for example "lines". Each row field is named with row.name("qty"), which gives "lines[0].qty". | string | none |
labelrequiredNames the whole group, for example "Invoice lines". | string | none |
descriptionHelp text under the label. | string | none |
itemLabelWhat one row is called, for example "Line". Used in button names and announcements. | string | "Row" |
defaultRowrequiredThe values of a new row, or a function that returns them. Each added row gets its own copy. | unknown | (() => unknown) | none |
minRowsFewest rows. Remove is disabled at this count. | number | 0 |
maxRowsMost rows. Add and Insert are disabled at this count, and a message says so. | number | none |
addLabelText of the Add button. | string | "Add row" |
emptyTextShown when there are no rows. | string | "No rows yet." |
allowReorderShows Move up and Move down on each row. | boolean | true |
densityComfortable draws each row as a card. Compact draws one tight line per row, for short rows such as an email and a role. | "comfortable" | "compact" | "comfortable" |
maxVisibleRowsIn compact density, the number of rows shown before the list scrolls inside itself, with a fade at the edge that has more. | number | none |
allowInsertShows Insert below on each row. | boolean | false |
validateChecks the rows as a whole, for example at least one with a quantity. The message shows under the group. | (rows: any[]) => string | null | undefined | void | none |
messagesReplaces any sentence the component says (button names, announcements). Use it to translate. | Partial<FieldArrayMessages> | none |
childrenrequiredRenders the fields of one row. Name them with row.name("field"). | (row: FieldArrayRow) => ReactNode | none |
Accessibility
Role group, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.
Keyboard
- Tab moves through the fields and the buttons of each row
- Enter or Space presses a row button or Add
- Move up and Move down are real buttons, not drag handles: nothing needs a pointer
- After Remove, focus moves to the Remove button of the row that took its place, else the row before, else Add
- After Add or Insert, focus moves to the first field of the new row
- After a move, focus stays on the button that was pressed
Screen readers announce
- Entering the group: the legend, then the description
- On a row button: its name, such as "Move line 2 up"
- After an action: a polite message such as "Line 3 removed" or "Line moved to position 1 of 4"
- At the maximum: the Add button is read as dimmed, and the message "You can add up to 5." is linked to the group
What your code must do
- The group is a fieldset named by its legend; each row is a labelled group ("Line 2")
- Every row button has a name that includes the row ("Remove line 2")
- Add, remove and move are announced in a polite status region ("Line 3 removed")
- Buttons that cannot act are disabled (the first row cannot move up, the minimum rows cannot be removed); a maximum is explained in text
- The empty state is text, not only an image or color
Guidelines
Use it when
- Invoice or order lines, phone numbers, team members, anything the person can repeat
- A list that needs reordering without a mouse
Avoid it when
- A fixed set of fields: lay them out directly
- Hundreds of rows: use DataGrid with editing
Don't
- Calculating totals here: read the rows with useFormValues in your own code
- Drag-only reordering: always keep the buttons
- Using the row index as a key in your own lists: use row.id, which stays with the row when it moves
Design tokens
The semantic tokens this component uses. Change them once and every component follows; see Design tokens.
--rd-color-surface-default--rd-color-border-default--rd-color-text-default--rd-color-text-muted--rd-color-feedback-danger--rd-color-focus-ring--rd-radius-control--rd-radius-overlay