Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, ChevronUp, GripVertical, Plus, Trash2 } from "lucide-react"
import {
  type Announcements,
  closestCenter,
  DndContext,
  type DragEndEvent,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
} from "@dnd-kit/core"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/repeater-field.json

Prompt

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 (or useFieldArray) stays the single source of truth.
  • Clamp with a reachable reasonmin/max mark controls aria-disabled instead of disabled so they keep focus and can be described by the visible "At least 2 rows required." / "Maximum of 3 rows reached." line, next to a 2/3 counter.
  • Keyboard-first reordering — chevron buttons move a row and announce "Moved to position 2 of 5" through a polite live region; dnd-kit's KeyboardSensor adds 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 notprefers-reduced-motion is read through useSyncExternalStore and drops dnd-kit's inline settle transition; rows snap instead of slide, and every reorder path still works.

On This Page