Repeater Field
A generic controlled array field — add, remove, move, and drag-sort rows of any shape, with min/max clamping and stable row ids that never remount a focused input.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/repeater-field.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "RepeaterField" component using
@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities and lucide-react. It is the
generic "add another row" field for form arrays: the consumer renders the body of
one row, the component owns the chrome (drag handle, move up/down, remove, add)
and the array algebra.
Contract
- export interface RepeaterRowApi<T extends object> { id: string; disabled:
boolean; canRemove: boolean; update: (patch: Partial<T> | ((prev: T) => T)) =>
void; remove: () => void; moveUp: () => void; moveDown: () => void }
- export interface RepeaterFieldProps<T extends object> extends
Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "children"> {
value: T[]; onChange: (next: T[]) => void; renderRow: (item: T, index: number,
api: RepeaterRowApi<T>) => React.ReactNode; createItem: () => T; getId?:
(item: T, index: number) => string; min?: number (0); max?: number;
addLabel?: string ("Add row"); emptyLabel?: string; reorderable?: boolean
(true); disabled?: boolean }
- Fully controlled: every mutation calls onChange with a brand-new array
(spread / filter / map / arrayMove) and never mutates the input.
- T is constrained to object because update() merges a partial patch; the
function form ((prev) => next) is the escape hatch for wholesale replacement.
- The component is generic, so it cannot go through forwardRef without losing its
type parameter: write the inner render function as (props, ref), wrap it in
forwardRef, then cast the export back to a generic function type.
Behavior
- Row identity is never the array index. If getId is supplied, use it. Otherwise
keep {source, ids, seq} in state and re-derive it during render (React's
adjust-state-during-render pattern, not an effect) whenever the value array
identity changes, in two passes: (1) object identity, so rows that survived a
filter or arrayMove keep their id and DOM node; (2) position, so an immutable
edit ({...row, email}) reuses the id at that index. Only leftovers mint a new
id from a per-instance counter seeded with React.useId(). Skipping pass (2) is
the classic repeater bug: every keystroke replaces the row object, React
remounts the row, and the input loses focus and caret mid-word.
- Add: append createItem() unless the array is at max. Remove: filter the index
out unless the array is at min. moveUp/moveDown: arrayMove, no-op at the ends.
- Clamps: floor min at 0, floor max at min (a max below min is a typo, not an
instruction), and treat non-finite numbers as absent. min never pads the array
for you — it only gates the remove control, so the consumer seeds the initial
rows.
- Clamped controls use aria-disabled, not the disabled attribute: they stay
focusable so a screen reader can read the reason wired through
aria-describedby, and so keyboard focus is never dropped on <body> when a row
reaches the top or bottom. Their handlers no-op, and pointer-events-none blocks
clicks. The field-level `disabled` prop is the opposite case — it uses the real
disabled attribute on every control it owns.
- Reachable reasons, not tooltips: at min render "At least N rows required.", at
max render "Maximum of N rows reached." plus an "N/max" counter, and point the
matching control's aria-describedby at them.
- Focus follows structural edits. After an add, focus the first focusable the
consumer rendered inside the new row (fall back to that row's remove button).
After a remove, focus the remove button that slid into the freed slot (or the
add button when the list empties). Drive this from a focus-intent state object
set in the handlers plus an effect keyed on it, and resolve rows by DOM
position under a ref on the list — an intent ref would be simpler, but the api
object escapes into consumer code, and refs must not be touched by anything
reachable during render.
- Drag: DndContext (PointerSensor with activationConstraint distance 4 so
clicking an input inside a row can't misfire a drag, plus KeyboardSensor with
sortableKeyboardCoordinates) + SortableContext with
verticalListSortingStrategy; each row calls useSortable and applies
CSS.Transform.toString(transform). Pin DndContext's id with useId or its
internal aria ids drift between server and client. Resolve both indices from
the id array on drag end and arrayMove.
- Announcements: the up/down buttons write "Moved to position 2 of 5." into an
aria-live="polite" role="status" sr-only region (add/remove write "Row 3
removed. 2 rows remaining."). Drag is narrated by dnd-kit's own live region
instead — override accessibility.announcements so it reads positions off the
drag event's sortable data rather than reading raw internal ids out loud, and
do NOT also announce on drag end or every drop is spoken twice.
- Empty array renders a dashed panel with emptyLabel and the add button, not an
empty box.
Rendering & styling
- Semantic tokens only: rows are `rounded-lg border bg-card p-2`, icon controls
are `text-muted-foreground` with `hover:bg-accent hover:text-accent-foreground`
and `focus-visible:ring-2 focus-visible:ring-ring`, remove hints danger with
`hover:bg-destructive/10 hover:text-destructive`, hints and the counter are
`text-xs text-muted-foreground`. No hex/oklch anywhere.
- cn() merges the consumer's className onto the root; the rest of the div props
spread onto it.
- The list is a `ul` with an explicit role="list" (display:flex drops list
semantics in WebKit); rows are `li` and the consumer's content sits in a
`min-w-0 flex-1` wrapper so long values truncate instead of overflowing.
- prefers-reduced-motion: dnd-kit writes its settle transition as an inline
style, which a CSS-only motion-reduce variant can't override, so read the media
query with useSyncExternalStore (server snapshot false) and drop the transition
in JS. Dragging keeps working; it just stops animating.
- Icons: GripVertical, ChevronUp, ChevronDown, Trash2, Plus.
Customization levers
- Chrome per row: `reorderable={false}` drops the grip and both chevrons (right
for key/value pairs where order carries no meaning); swap Trash2 for X, or move
the control cluster to the left of the content.
- Row shape: everything about a row is renderRow's business — one input, a grid
of four fields, a nested card. Use api.id to wire `<label htmlFor>` and
api.disabled to disable your own inputs.
- Density: rows are `gap-2 p-2`; drop to `p-1.5` for compact tables, raise to
`p-3` with `gap-3` for card-like rows.
- Clamping copy: the min/max hint strings and addLabel/emptyLabel are the only
user-visible English in the component — pass your own for i18n.
- Persisted rows: pass getId to key rows by a database id and skip identity
tracking entirely.
- Bigger jobs this deliberately does not do: cross-list dragging (kanban), nested
repeaters inside a row (works, but each level needs its own field), and
validation — pair it with react-hook-form's useFieldArray or zod at the form
level and render per-row errors inside renderRow.Concepts
- Stable row identity — the React key comes from tracked identity, never from the array index. Identity survives reorders (object identity) and edits (positional reuse), which is exactly what keeps a half-typed input from remounting.
- Controlled array algebra — the field owns no rows; add/remove/move/drag all resolve to a fresh array handed back through
onChange, so the consumer's state (oruseFieldArray) stays the single source of truth. - Clamp with a reachable reason —
min/maxmark controlsaria-disabledinstead ofdisabledso they keep focus and can be described by the visible "At least 2 rows required." / "Maximum of 3 rows reached." line, next to a2/3counter. - Keyboard-first reordering — chevron buttons move a row and announce "Moved to position 2 of 5" through a polite live region; dnd-kit's
KeyboardSensoradds pick-up-and-arrow dragging on the grip, narrated by its own live region with positions instead of internal ids. - Focus follows the edit — adding focuses the first input in the new row, removing focuses the remove button that took its place, so a keyboard user is never dumped back onto
<body>after a structural change. - Motion is optional, dragging is not —
prefers-reduced-motionis read throughuseSyncExternalStoreand drops dnd-kit's inline settle transition; rows snap instead of slide, and every reorder path still works.
Unit Input
A number-plus-unit field for design tokens — 12px, 1.5rem, 50% — with arrow/wheel stepping, optional conversion on unit switch, and an auto state.
Timezone Picker
A searchable IANA time-zone field — ~90 zones grouped Popular / All, each row showing that zone's live local time and GMT offset, with a portalled panel that no overflow-hidden card can clip.