1 slot open this monthBy the makers of shadcnregistryWe built shadcnregistry. Now we build your product.Book a callCheck availability
1 slot open this monthAI features?We build AI features into your product.Book a callCheck availability
1 slot open this month🚨 Slop police🚨 Slop police: we clean up vibe-coded apps.Report itReport your app
shadcnregistry
shadcnregistry
RegistryHealthInspect
Terms of ServicePrivacy Policy
  1. Registries
  2. pulld
  3. Repeatable Fields

Repeatable Fields

ui

A set of identical field rows with an "add another" button under them and a remove button on each: the caller renders one row's fields, this owns the list, the identity of each row, and where focus goes when the list changes shape. Reach for it wherever a form takes a list rather than a value: work history and education on a CV or profile, the line items of an invoice, quote or expense claim, environment variables and their values in a deploy or settings screen, several emails, phone numbers or addresses on one contact, the headers, query parameters or body fields of an API request builder, a product's variants and their prices, the answer options of a survey or poll being written, team members being invited by email, redirect URIs and allowed origins on an OAuth app, webhook endpoints, ingredients and steps in a recipe, authors on a paper, guests on a booking, and the "add another" row of any admin screen that edits a one-to-many relation. Common asks it answers: "repeatable fields react", "repeating form fields", "dynamic form fields react", "add more fields button react", "add another row form", "add remove input rows react", "field array react", "useFieldArray without react-hook-form", "useFieldArray alternative", "react-hook-form field array shadcn", "shadcn repeatable fields", "shadcn dynamic form rows", "shadcn add another field", "shadcn field array", "shadcn invoice line items", "multiple email inputs react", "add multiple phone numbers form", "key value pair input react", "environment variables input react", "http headers input react", "invoice line items component react", "work experience form repeatable", "education fields add more", "product variants form react", "survey options editor react", "add remove list of inputs", "duplicate form row react", "variable length form react", "array of objects form react", "nested form array react", "deleting a row shows the wrong value", "removing an item keeps the value of the next one", "react list delete wrong item", "input value jumps to another row after delete", "wrong row deleted react", "deleted row value appears in the row below", "why not to use index as key", "react key index bug form inputs", "stable keys for dynamic form rows", "uncontrolled input loses value on remove", "focus new row after adding react", "autofocus newly added input react", "focus first field of new row", "focus lost after removing a row", "focus jumps to body after delete react", "tab starts from top of page after removing a row", "where should focus go after deleting an item", "manage focus when removing an element react", "screen reader does not announce added row", "announce row added aria-live", "remove buttons all say remove", "accessible name for delete row button", "label each remove button by its row", "aria-label for repeated delete buttons", "fieldset legend repeated fields", "group repeated form fields accessibility", "minimum one row required form", "limit number of rows form react", "disable add button at max rows", "keep at least one field react", "可変長フォーム react", "入力欄を動的に追加", "もう1つ追加 ボタン", "入力行の追加と削除", "明細行の追加 フォーム", "環境変数の入力欄", "配列のフォーム react", "index を key にすると壊れる", "行を削除したら下の行の値がずれる", "追加した行にフォーカスを移す", "削除後のフォーカス移動", "削除ボタンの読み上げ名". Official shadcn/ui has the shell and none of the behaviour, and the measurement says so precisely. Across its whole registry today — 63 items in the catalogue, 62 fetchable, `questionnaire` indexed and 404 on both style tracks, 223,287 bytes of `files[].content` concatenated — `useFieldArray`, `fieldArray`, `repeatable`, `Add another`, `appendField`, `removeField` and `duplicateRow` are every one of them a zero hit. What official does ship is `field`, and it is worth being exact about what that gives you: `FieldSet` is a styled `<fieldset>`, `FieldLegend` a styled `<legend>`, `FieldGroup` a flex column, and it owns every one of the 2 `fieldset` and 7 `legend` occurrences in the registry. In its 6,170 bytes the words `add` and `remove` do not appear once. So the grouping is solved and the repetition is not. `form` is the other near-miss: 3,758 bytes wiring one field to its label, description and message — `FormField` occurs 17 times in it — and a field array is not in its vocabulary at all. This component is deliberately not built on either of them: the shell is nine lines of flexbox, and a project that has installed `field` can hand this component's `className` the same classes and the two sit together. Five things go wrong in the thirty-line version, and every one of them is invisible to a mouse. The first is the famous one, and the usual summary of it — "don't use the index as a key" — names the fix rather than the failure. React uses the key to decide which previously rendered row a row corresponds to. Keyed by position, deleting the second of three rows means the row that was third is now second, so React matches it to the second row's DOM nodes and keeps them — and an `<input>`'s text is DOM state, not something React re-renders. The value typed into row 3 is then sitting in row 2's box: nothing throws, the row count is right, every box has text in it, and what the person sees is the row they deleted having eaten the row below it. Uncontrolled inputs lose the value outright; controlled ones recover on the next render, but the caret, the selection, the scroll position and anything a ref is holding do not. So identity has to come from somewhere other than the position, and this mints a key per row and keeps it — rather than requiring an `id` on each row, which would be wrong here because these rows are form values that get submitted, and a synthetic id the caller adds on the way in and strips on the way out is the kind of chore that gets skipped. Keys survive the caller replacing the array too, matched by object identity and consumed as they are claimed, because `rows.filter(...)`, a reorder and an append all preserve the identity of the rows they keep. Second, focus after adding: left on the add button, the new row's first field is one Tab per field in every row above it, so a long form punishes the keyboard for every row it gains. Focus moves into the new row's first field here, which is also why the remove button is rendered after the row's fields — `querySelector` answers in document order, and the selector matches a button too, so putting it first would open every fresh row with focus on the control that deletes it. Third, focus after removing: the button just pressed no longer exists, and a browser does not move focus when the node holding it is removed — it drops focus to `<body>`, so the next Tab starts again at the top of the page and a screen reader says nothing. The rule here is the row that took the vacated slot, else the row now last, else the add button, which means walking a list of six down to one is six presses in one place rather than six presses and five hunts for the button. Fourth, the remove buttons: n of them called "Remove" tell a screen-reader user how many rows there are and nothing about which button takes which, so each is named for its row — "Remove variable 2 of 3" — and each row is a `role="group"` named the same way, so entering one says where you are. Fifth, the floor and the ceiling: `min` and `max` are `aria-disabled` rather than the `disabled` attribute, so the control keeps its tab stop and announces itself as unavailable instead of silently vanishing or sitting there doing nothing, and the reason is on screen and wired up with `aria-describedby`. Adding and removing are also announced through a polite live region, because neither changes any field's value — without it the only evidence a row appeared is that focus moved, which says where you are and not what happened. What you get: `RepeatableFields`, generic over your row type, controlled with `value` + `onChange` (handed the whole next array and a `{ type, index }` saying what changed) or uncontrolled with `defaultValue`; `newRow`, the factory for a blank row; `children` as a render function receiving `{ row, index, total, rowId, update, remove, canRemove }`, where `rowId` is the stable minted key handed over so a label in row 2 can point at row 2's input; `legend` and `description` in a real `<fieldset>`/`<legend>`; `emptyMessage` for a set with nothing in it; `min` and `max`; `noun`, one prop that rewrites every accessible name at once ("Add another recipient", "Remove recipient 2 of 3"); `disabled`, which goes on the fieldset so the browser disables the caller's fields too without a prop being threaded down; `labels` for translating any of it; and per-part `className` for the set, a row, and each button. `nextFocusAfterRemove` and `alignRowKeys` are exported as pure functions, so the two decisions that are actually hard — where focus lands and which row keeps which key — can be asserted on their own. One file, two lucide icons, no `react-hook-form` and no form library; the array drops straight into `react-hook-form`'s `Controller` or any controlled state, and the fields are yours, so their `name` attributes and validation stay where they were. Distinct from the neighbours, and the pair worth knowing is `tag-input`: that one holds a `string[]` of short free-text values as chips inside a single field, this one repeats a whole row of several fields. `multi-select` picks from a fixed set of options you supply, and `nested-checkbox` selects from a hierarchy — both choose among things that already exist, where this one makes new ones. `sortable-list` reorders rows that already carry their own `id` and does not add or remove them. `upload-list` and `filter-chips` show a collection the person did not type. `form-error-summary` collects what went wrong after a submit, and `weekly-hours` is a fixed seven rows rather than a list that grows.

pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/repeatable-fields.json"
Premium blocks for shadcn/uiShadcnblocks.com · AffiliateExplore
  • lucide-react
    components/ui/repeatable-fields.tsx