A short list of rows the reader adds and removes, each row your own fields.

Rows the reader adds

A Repeater is a fieldset of rows, each row a fieldset of your own fields with a legend by its place, "Tier 2", Remove at its end and Add after the last. Use it for a short list a person fills in, such as wait tiers, extra email addresses or the people on a shared budget. For a long list, or one the reader sorts by hand, use a table or a list with its own page per item.

Controlled, value holds your typed rows and onChange receives the new rows on every add, remove and update. createRow makes the row Add appends, with an id of its own that stays the same across adds, removes and saves. The render function draws a row's fields and gets the row, its index, its label, its error and update, which writes the row back by its id.

Example

Wait tiers

A payment waits by its amount, the larger the longer.

  1. Tier 1
    Changed
    $
    WaitChanged
  2. Tier 2
    Changed
    $
    WaitChanged
2 of 4
Follows the page theme and corners
'use client';

import { NumberField, PickerOption, Repeater, Select } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

/** A wait tier: payments up to an amount, in cents, wait this long before they count. */
interface Tier {
  id: string;
  amount: number | null;
  wait: string;
}

const WAITS = [
  ['day', 'A day'],
  ['three', '3 days'],
  ['week', 'A week'],
  ['month', 'A month'],
] as const;

let made = 0;

export default function RepeaterBasic(): ReactElement {
  const [tiers, setTiers] = useState<Tier[]>([
    { id: 'small', amount: 10000, wait: 'three' },
    { id: 'large', amount: 50000, wait: 'week' },
  ]);
  return (
    <Repeater<Tier>
      label="Wait tiers"
      description="A payment waits by its amount, the larger the longer."
      value={tiers}
      onChange={setTiers}
      // A new row with an id of its own, stable across adds, removes and saves.
      createRow={() => {
        made += 1;
        return { id: `tier-${String(made)}`, amount: null, wait: 'three' };
      }}
      rowLabel={(index) => `Tier ${String(index + 1)}`}
      maxRows={4}
      labels={{ add: 'Add a tier' }}
      style={{ width: '100%', maxWidth: '40rem' }}
    >
      {(tier, { update }) => (
        <>
          <NumberField
            label="Up to"
            money={{ symbol: '$' }}
            value={tier.amount}
            onChange={(amount) => {
              update({ ...tier, amount });
            }}
          />
          <Select
            label="Wait"
            value={tier.wait}
            onChange={(key) => {
              update({ ...tier, wait: String(key) });
            }}
          >
            {WAITS.map(([id, name]) => (
              <PickerOption key={id} id={id}>
                {name}
              </PickerOption>
            ))}
          </Select>
        </>
      )}
    </Repeater>
  );
}

Controlled or not

Without value the Repeater holds the rows itself, from defaultValue, and the fields hold what is typed: name them, and read them from the form. Two ways to name them, both of which keep the drawn order:

  • Indexed names, from the index the render function passes: tiers.0.amount, tiers.0.wait. After a remove every row is named by its new place, so no index is stale and none is missing, and what a form sends reads back into the same rows. Form libraries' field arrays name rows this way. Put the id in a hidden input, as below, so a saved row keeps it.
  • Repeated names: every row's amount tierAmount, read with formData.getAll('tierAmount') in drawn order. A money NumberField sends minor units from its own hidden input, which getAll reads like any other.

Controlled, send the rows you hold: they are already typed, with their ids, and a server action takes them as they are.

A form reset puts back the rows the Repeater started with, as it puts back every field: a removed row returns with its first values and an added one leaves. Controlled, onChange receives those rows.

Example

Wait tiers
  1. Tier 1
    Changed
    $
    WaitChanged
  2. Tier 2
    Changed
    $
    WaitChanged
2 of 4
Follows the page theme and corners
'use client';

import { Button, NumberField, PickerOption, Repeater, Select } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

interface Tier {
  id: string;
  amount: number | null;
  wait: string;
}

const WAITS = [
  ['three', '3 days'],
  ['week', 'A week'],
  ['month', 'A month'],
] as const;

/** Rows from indexed names, as a server action reads them: tiers.0.amount, tiers.0.wait, and so on. */
function readTiers(data: FormData): Tier[] {
  const tiers: Tier[] = [];
  for (const [name, value] of data) {
    const match = /^tiers\.(\d+)\.(id|amount|wait)$/.exec(name);
    if (!match || typeof value !== 'string') continue;
    const tier = (tiers[Number(match[1])] ??= { id: '', amount: null, wait: '' });
    if (match[2] === 'id') tier.id = value;
    if (match[2] === 'amount') tier.amount = value === '' ? null : Number(value);
    if (match[2] === 'wait') tier.wait = value;
  }
  return tiers;
}

let made = 0;

export default function RepeaterForm(): ReactElement {
  const [sent, setSent] = useState<Tier[] | null>(null);
  return (
    <form
      className="stack"
      style={{ width: '100%', maxWidth: '40rem' }}
      onSubmit={(event) => {
        event.preventDefault();
        // What a server action would receive, read back into typed rows.
        setSent(readTiers(new FormData(event.currentTarget)));
      }}
    >
      {/* Uncontrolled: the Repeater holds the rows, the fields hold what is typed. */}
      <Repeater<Tier>
        label="Wait tiers"
        defaultValue={[
          { id: 'small', amount: 10000, wait: 'three' },
          { id: 'large', amount: 50000, wait: 'week' },
        ]}
        createRow={() => {
          made += 1;
          return { id: `tier-${String(made)}`, amount: null, wait: 'three' };
        }}
        rowLabel={(index) => `Tier ${String(index + 1)}`}
        maxRows={4}
        labels={{ add: 'Add a tier' }}
      >
        {(tier, { index }) => (
          <>
            {/* Indexed names follow the drawn order, so a remove leaves no gap and FormData round-trips. */}
            <input type="hidden" name={`tiers.${String(index)}.id`} value={tier.id} />
            <NumberField
              label="Up to"
              money={{ symbol: '$' }}
              name={`tiers.${String(index)}.amount`}
              defaultValue={tier.amount}
            />
            <Select label="Wait" name={`tiers.${String(index)}.wait`} defaultValue={tier.wait}>
              {WAITS.map(([id, name]) => (
                <PickerOption key={id} id={id}>
                  {name}
                </PickerOption>
              ))}
            </Select>
          </>
        )}
      </Repeater>
      <div className="row">
        <Button type="submit" variant="secondary">
          Save
        </Button>
      </div>
      <output>
        {sent === null
          ? null
          : `Sent: ${sent.map((tier) => `${tier.id} up to ${String(tier.amount)} waits ${tier.wait}`).join('; ')}`}
      </output>
    </form>
  );
}

From none to the most

With no rows the list is not drawn and labels.empty says so in its place, "Only you get the weekly email.", above Add. Pass a sentence that tells the reader what no rows means.

maxRows is required, since a repeater is a short list. Under it Add is described by its count, "1 of 2". At it Add stays where it was, drawn and disabled, and labels.full takes the count's place beside it: "2 of 2. Remove an address to add another." A disabled button leaves the tab order, so the reason is reached three ways: it is visible beside Add, it is Add's description, which a screen reader in browse mode reads with the button, and the add that reached the maximum says it in the status. Rows you pass above maxRows, from data saved before a limit changed, are all drawn; nothing is cut.

minRows, 0 by default, is where Remove stops: at it no row draws Remove, so nothing invites a press that cannot work, and the column Remove stood in keeps its width.

Example

Also send the weekly email to

Only you get the weekly email.

0 of 2
Follows the page theme and corners
'use client';

import { Repeater, TextField } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

interface Address {
  id: string;
  email: string;
}

let made = 0;

export default function RepeaterLimits(): ReactElement {
  // From no rows: the empty line, then up to two, where Add stays drawn, disabled, with its reason beside it.
  const [addresses, setAddresses] = useState<Address[]>([]);
  return (
    <Repeater<Address>
      label="Also send the weekly email to"
      value={addresses}
      onChange={setAddresses}
      createRow={() => {
        made += 1;
        return { id: `address-${String(made)}`, email: '' };
      }}
      rowLabel={(index) => `Address ${String(index + 1)}`}
      maxRows={2}
      labels={{
        add: 'Add an address',
        empty: 'Only you get the weekly email.',
        full: (max) => `${String(max)} of ${String(max)}. Remove an address to add another.`,
      }}
      style={{ width: '100%', maxWidth: '40rem' }}
    >
      {(address, { update }) => (
        <TextField
          label="Email"
          type="email"
          value={address.email}
          onChange={(email) => {
            update({ ...address, email });
          }}
        />
      )}
    </Repeater>
  );
}

Errors

Three kinds, each where it belongs:

  • A field's own, a required amount or one out of range, through the field's errorMessage or validate, under the field.
  • A row's, rowErrors by row id: a line under the row's fields that describes the row's fieldset, so it is read on entering the row. The render function gets it as error, so mark the row's fields invalid with it.
  • The set's, errorMessage: a line under the legend that describes the set. While it shows, the Changed chip steps aside.

An error is words in the error color and the fields' own invalid edges, never a colored row.

Example

Wait tiers

Tiers must go up by amount

  1. Tier 1
    Changed
    $
  2. Tier 2
    $

    Tier 1 already waits from $100.00

  3. Tier 3
    Changed
    $
3 of 4
Follows the page theme and corners
'use client';

import { NumberField, Repeater } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

interface Tier {
  id: string;
  amount: number | null;
}

/** Dollars from cents, for an error sentence, without Intl: the field shows the figure itself. */
const dollars = (cents: number): string => `$${(cents / 100).toFixed(2)}`;

let made = 0;

export default function RepeaterErrors(): ReactElement {
  const [tiers, setTiers] = useState<Tier[]>([
    { id: 'small', amount: 10000 },
    { id: 'repeat', amount: 10000 },
    { id: 'lower', amount: 5000 },
  ]);
  // A row's error: an amount another tier already starts from. Yours to check, by row id.
  const rowErrors: Record<string, string> = {};
  tiers.forEach((tier, index) => {
    const earlier = tiers.slice(0, index).findIndex((other) => other.amount === tier.amount);
    if (tier.amount !== null && earlier !== -1) {
      rowErrors[tier.id] = `Tier ${String(earlier + 1)} already waits from ${dollars(tier.amount)}`;
    }
  });
  // The set's error: the tiers out of order. You sort them when you save, so this is a hint before it.
  const sorted = tiers.every(
    (tier, index) => index === 0 || (tier.amount ?? 0) >= (tiers[index - 1]?.amount ?? 0),
  );
  return (
    <Repeater<Tier>
      label="Wait tiers"
      value={tiers}
      onChange={setTiers}
      createRow={() => {
        made += 1;
        return { id: `tier-${String(made)}`, amount: null };
      }}
      rowLabel={(index) => `Tier ${String(index + 1)}`}
      maxRows={4}
      labels={{ add: 'Add a tier' }}
      rowErrors={rowErrors}
      errorMessage={sorted ? undefined : 'Tiers must go up by amount'}
      style={{ width: '100%', maxWidth: '40rem' }}
    >
      {(tier, { update, error }) => (
        <NumberField
          label="Up to"
          money={{ symbol: '$' }}
          value={tier.amount}
          isRequired
          // The row's error marks its field invalid; the field's own error, a required amount, is the field's.
          isInvalid={error !== undefined}
          onChange={(amount) => {
            update({ ...tier, amount });
          }}
        />
      )}
    </Repeater>
  );
}

Focus and what is said

Focus moves in the frame after React's commit, to a place the reader can name, and never falls to the page's body. A polite status, in the server's HTML and empty until then, says what happened, once focus is in its place, so a screen reader reading the focus move does not cut it short:

  • Add: the new row's first field. "Tier 3 added".
  • Add that reaches the maximum: the new row's first field. "Tier 3 added. 3 of 3. Remove one to add another."
  • Remove, a row with rows after it: Remove in the row now in its place. "Tier 2 removed".
  • Remove, the last of several: Remove in the new last row. "Tier 3 removed".
  • Remove, the only row: Add. "Tier 1 removed".

A remove keeps the reader on the same control, one row on, so removing several rows is one press after another; at minRows, where no Remove is left, focus goes to that row's first field instead. A held Enter removes one row: the key's repeat starts no press on the next Remove. The status says which row left by its old legend, since the rows after it are renumbered. A field marked data-autofocus in a row takes focus after an add in place of its first field. Controlled, your onChange decides: an add or a remove you refuse, by keeping the rows you hold, says nothing and moves nothing.

Keep the Repeater outside anything you mark aria-busy="true" while saving: a screen reader may hold back a busy region's changes, so set aria-busy on your fields' container, not around the Repeater. It warns once in development when its status speaks inside one.

In a card

A Repeater that ends a card puts Add at the card's bottom corner, and moves it clear of the curve by itself, as every control at a card's foot does: 7px in Rounded, 19px compact, nothing in Soft and Square. Under a group's offer line from field extras, it moves only while no offer follows it.

Example

Coach

Wait tiers
  1. Tier 1
    Changed
    $
    WaitChanged
  2. Tier 2
    Changed
    $
    WaitChanged
2 of 3
Follows the page theme and corners
'use client';

import {
  Card,
  CardBody,
  CardHeader,
  CardTitle,
  NumberField,
  PickerOption,
  Repeater,
  Select,
} from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

interface Tier {
  id: string;
  amount: number | null;
  wait: string;
}

const WAITS = [
  ['three', '3 days'],
  ['week', 'A week'],
  ['month', 'A month'],
] as const;

let made = 0;

export default function RepeaterCard(): ReactElement {
  const [tiers, setTiers] = useState<Tier[]>([
    { id: 'small', amount: 10000, wait: 'three' },
    { id: 'large', amount: 50000, wait: 'week' },
  ]);
  return (
    <Card as="section" aria-labelledby="repeater-card-title" style={{ width: '100%', maxWidth: '40rem' }}>
      <CardHeader>
        <CardTitle id="repeater-card-title" level={3}>
          Coach
        </CardTitle>
      </CardHeader>
      {/* The Repeater ends the card, so Add moves clear of its bottom corner by itself. */}
      <CardBody>
        <Repeater<Tier>
          label="Wait tiers"
          value={tiers}
          onChange={setTiers}
          createRow={() => {
            made += 1;
            return { id: `tier-${String(made)}`, amount: null, wait: 'three' };
          }}
          rowLabel={(index) => `Tier ${String(index + 1)}`}
          maxRows={3}
          labels={{ add: 'Add a tier' }}
        >
          {(tier, { update }) => (
            <>
              <NumberField
                label="Up to"
                money={{ symbol: '$' }}
                value={tier.amount}
                onChange={(amount) => {
                  update({ ...tier, amount });
                }}
              />
              <Select
                label="Wait"
                value={tier.wait}
                onChange={(key) => {
                  update({ ...tier, wait: String(key) });
                }}
              >
                {WAITS.map(([id, name]) => (
                  <PickerOption key={id} id={id}>
                    {name}
                  </PickerOption>
                ))}
              </Select>
            </>
          )}
        </Repeater>
      </CardBody>
    </Card>
  );
}

Usage

A client component: every row, its fields, its Remove and Add, or the full line, and the empty status are in the server's HTML, and nothing moves on hydration. The words are the library's, in English, so a page in another language passes its own labels.

import { NumberField, Repeater } from '@snapshot-ui/react';

<Repeater
  label="Wait tiers"
  value={tiers}
  onChange={setTiers}
  createRow={() => ({ id: crypto.randomUUID(), amount: null })}
  rowLabel={(index) => `Tier ${index + 1}`}
  maxRows={3}
  labels={{ add: 'Add a tier' }}
>
  {(tier, { update, error }) => (
    <NumberField
      label="Up to"
      money={{ symbol: '$' }}
      value={tier.amount}
      isInvalid={error !== undefined}
      onChange={(amount) => update({ ...tier, amount })}
    />
  )}
</Repeater>;

Rows have no reorder. When the order means something, as wait tiers by amount, sort the rows when you save, and say so with a set error before it if you like: sorting while the reader types would move a row under the caret.

Props

className, ref and any other attribute go on the root, fieldset.repeater.

changedLabel
string

The Changed chip's text.

Default: 'Changed'

children
(row: RepeaterRow, render: RepeaterRowRender<RepeaterRow>) => ReactNode

Draws one row's fields.

className
string

createRow
() => RepeaterRow

Makes the row Add appends, with a new id.

defaultValue
readonly RepeaterRow[]

Uncontrolled: the first rows.

Default: []

description
ReactNode

A hint under the legend.

errorMessage
ReactNode

An error about the whole set.

isChanged
boolean

Whether the set differs from the saved one: the Changed chip beside the legend.

isDisabled
boolean

Adds and removes are refused; the fields are the app's to disable.

label
ReactNode

The set's legend: "Wait tiers".

labels
Partial<RepeaterLabels>

The library's words, in English unless replaced.

maxRows
number

Rows Add stops at. Required: a repeater is a short list.

minRows
number

Rows Remove stops at.

Default: 0

onChange
((rows: RepeaterRow[]) => void)

Every add, remove and update, with the new rows.

rowErrors
Readonly<Record<string, ReactNode>>

Errors by row id, drawn under the row's fields.

rowLabel
(index: number) => string

Each row's legend, from its place: (index) => Tier ${index + 1}.

value
readonly RepeaterRow[]

Controlled: the rows, in order.

error
ReactNode

The row's error, from rowErrors, so its fields can mark themselves invalid.

index
number

The row's place, from 0, as drawn now. Indexed form names use it: tiers.${index}.amount.

label
string

The row's legend as drawn: rowLabel(index).

update
(row: RepeaterRow) => void

Writes this row back into the rows, by its id: update({ ...row, amount }). Calls onChange.

add
string

Add's text.

Default: 'Add a row'

added
(label: string) => string

Said politely after an add.

Default: (label) => `${label} added`

count
(count: number, max: number) => string

Add's description under the maximum: "2 of 3".

Default: (count, max) => `${count} of ${max}`

empty
string

In the rows' place when there are none.

Default: 'None yet.'

full
(max: number) => string

Beside the disabled Add at the maximum, and its description.

Default: (max) => `${max} of ${max}. Remove one to add another.`

remove
string

Remove's visible text; its name adds the row's legend: "Remove, Tier 2".

Default: 'Remove'

removed
(label: string) => string

Said politely after a remove.

Default: (label) => `${label} removed`

Accessibility

Fieldsets, legends and buttons, and no composite widget: the set is read in browse mode and filled with Tab, row by row. Each row's fields keep their visible labels, and the row's legend gives them their place, "Tier 2, group, Up to". The list is ordered, since a row's place is its meaning.

Remove shows its word and is named with its row, "Remove, Tier 2", so a list of Removes reads as a list of rows, and voice control finds it by the word it shows. Add is named by its text and described by its count, or at the maximum by its reason.

TabMoves through each row's fields and its Remove, row by row, then to Add.
Shift+TabMoves back the same way.
EnterSpaceOn Add, adds a row and focuses its first field; on Remove, removes the row and focuses the same control one row on.

Design rules

What keeps a repeater in the system.

Give each row a legend by its place, such as Tier 2.

Sort the rows when you save, when their order means something.

Pair it with a save bar, whose Cancel brings a removed row back.

Never reorder rows by drag here.

Never color a row in error. Say it in words under its fields.

Never remove a row with nothing to undo it: no save bar, no Cancel.

esc

↑ ↓ to move↵ to open