Skip to content

Field Array

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.

Inputv0.1.0experimentalWCAG 2.2 AAView spec
Invoice lines
 

Total: $800.00

 
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-array

Copies 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.json

Works, 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.

PropTypeDefault
namerequired

Where the rows live in the Form values, for example "lines". Each row field is named with row.name("qty"), which gives "lines[0].qty".

stringnone
labelrequired

Names the whole group, for example "Invoice lines".

stringnone
description

Help text under the label.

stringnone
itemLabel

What one row is called, for example "Line". Used in button names and announcements.

string"Row"
defaultRowrequired

The values of a new row, or a function that returns them. Each added row gets its own copy.

unknown | (() => unknown)none
minRows

Fewest rows. Remove is disabled at this count.

number0
maxRows

Most rows. Add and Insert are disabled at this count, and a message says so.

numbernone
addLabel

Text of the Add button.

string"Add row"
emptyText

Shown when there are no rows.

string"No rows yet."
allowReorder

Shows Move up and Move down on each row.

booleantrue
density

Comfortable 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"
maxVisibleRows

In compact density, the number of rows shown before the list scrolls inside itself, with a fade at the edge that has more.

numbernone
allowInsert

Shows Insert below on each row.

booleanfalse
validate

Checks the rows as a whole, for example at least one with a quantity. The message shows under the group.

(rows: any[]) => string | null | undefined | voidnone
messages

Replaces any sentence the component says (button names, announcements). Use it to translate.

Partial<FieldArrayMessages>none
childrenrequired

Renders the fields of one row. Name them with row.name("field").

(row: FieldArrayRow) => ReactNodenone

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