Source Code

<div class="demo">
  <form class="field-array-form" id="farrayForm" novalidate>
    <div class="fa-head">
      <label>Team member emails</label>
      <span class="fa-count" id="faCount">1 field</span>
    </div>

    <div class="fa-rows" id="faRows">
      <div class="fa-row" data-index="0">
        <input type="email" class="fa-input" placeholder="teammate@company.com" required />
        <button type="button" class="fa-remove" aria-label="Remove field" disabled>
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
        </button>
      </div>
    </div>

    <button type="button" class="fa-add" id="faAdd">
      <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
      Add another email
    </button>

    <button type="submit" class="fa-submit">Send invites</button>
    <p class="fa-status" id="faStatus" role="status" aria-live="polite"></p>
  </form>
</div>

Dynamic Field Array — Add and Remove Repeatable Form Rows in Vanilla JS

Dynamic Field Array — Add/Remove Repeatable Form Rows · Forms · Plain HTML, CSS & JS · Live preview

What's included

Features

Fully dynamic add/remove — validation and row-count logic re-query the live DOM rather than assuming a fixed field list
Last remaining row's remove button is genuinely disabled (native disabled attribute), preventing a zero-row form state
Newly added rows and the row already present in the initial HTML share identical remove behavior
Per-row email format validation on submit, with the first invalid field automatically focused
Live field-count label updates correctly as rows are added or removed
Unique auto-incrementing index per row, independent of current DOM position, ready for real field naming
Accessible status region (role="status" aria-live="polite") announces validation and submission outcomes
No form library dependency — the entire field-array pattern is implemented in plain vanilla JavaScript

About this UI Snippet

Dynamic Field Array — Add/Remove Rows Without a Form Framework

Screenshot of the Dynamic Field Array — Add/Remove Repeatable Form Rows snippet rendered live

Forms that collect an unknown number of similar values — invite emails, phone numbers, URLs, line items — need a way for the user to add or remove input rows on the fly. This is normally handled by a form library's "field array" abstraction (React Hook Form, Formik, etc.); this snippet implements the same idea directly in vanilla JavaScript, with the same correctness guarantees a library version would provide.

Every row is fully self-contained, created identically

createRow() builds one complete .fa-row — an <input> and its own .fa-remove button, with the remove button's click listener attached at creation time — and returns it as a single DOM fragment ready to append. This matters because it means dynamically-added rows and the one static row already in the HTML need to behave *identically*; the snippet handles this by explicitly wiring up a click listener on the pre-existing HTML row separately, right after the createRow function is defined, so both code paths converge on the same removal behavior.

The last row can never be deleted

refreshRemoveButtons() runs after every add or remove, and disables every row's remove button whenever exactly one row remains — rows.length <= 1. This prevents a genuinely broken state (a form with zero input rows and no way to add one back except reloading the page) that a naive "just delete on click" implementation would allow. The disabled state is a real HTML disabled attribute, not a class-based fake, so it's also correctly skipped by keyboard Tab order.

Validation runs across the whole dynamic set, not a fixed list

On submit, rowsEl.querySelectorAll('.fa-input') re-queries the DOM for whatever rows currently exist — however many the user has added or removed — rather than relying on a hardcoded list of expected field references. Each input is checked against a real email-format regex, invalid ones get a visible .invalid border, and the *first* invalid field receives focus, so the user is taken directly to the first problem rather than having to hunt for it themselves across several similar-looking rows.

Why unique row indices matter even though this demo doesn't use them for naming

Each new row is tagged with an auto-incrementing data-index, distinct from the current DOM position (which shifts as rows are removed). This is worth keeping even in a simple demo because a real backend integration would typically need distinct field names per row (emails[0], emails[1], etc.) — using an ever-incrementing counter rather than the row's current array position avoids two different rows accidentally colliding on the same generated name after some rows have been removed and others added.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Ask an AI assistant to explain why re-querying the DOM for the current set of rows at submit time (rather than tracking a separate JavaScript array of row state) is a robust approach here, and what could go wrong if row state and DOM state were allowed to drift apart. It's also worth asking for a version where each row holds multiple related fields (like a name and an email together), or one that supports drag-to-reorder rows in addition to add/remove.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a dynamic repeatable form-field pattern in HTML, CSS and vanilla JavaScript where users can add and remove input rows freely — no form library, no external dependencies.

Requirements:
- Start with one input row (an email field plus a remove button) already present in the HTML, and an "Add another" button that appends new, fully independent rows built the same way in JavaScript.
- Every row's remove button — both the one in the initial HTML and any dynamically created ones — must behave identically and actually remove that specific row from the DOM.
- The very last remaining row's remove button must become genuinely disabled (using the native disabled attribute) whenever only one row is left, so the form can never be reduced to zero input rows.
- On form submit, validate every currently-existing row's email format (re-querying the DOM rather than relying on a fixed list built at page load), mark invalid fields visibly, and move keyboard focus to the first invalid field found.
- Show a live count of how many fields currently exist, updating correctly after every add or remove.
- Use an accessible live region to announce the submit result (validation failure or success) to screen reader users.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Click "Add another email" to append a rowEach new row is a fully independent input plus a working remove button, and gets focused automatically after being added.
  2. 2
    Remove any row via its × buttonEvery row's remove button works identically, including the one present in the initial HTML.
  3. 3
    Try removing down to one rowThe last remaining row's remove button becomes genuinely disabled — the form can never be left with zero input rows.
  4. 4
    Submit with an empty or malformed rowValidation runs across every currently-existing row and focuses the first invalid one, regardless of how many rows have been added or removed.
  5. 5
    Swap the input type or add more fields per rowExtend createRow() to build a more complex row (e.g. name + email pair) — the add/remove/validate logic already generalizes to any row shape.

Real-world uses

Common Use Cases

INVITE
Team Invite Forms
Let a user invite an arbitrary number of teammates by email in one submission, as shown in this demo.
Multi-Value Contact Fields
Collect a variable number of phone numbers, URLs, or addresses in forms that don't know the count up front.
COMMERCE
Dynamic Line-Item Entry
Adapt the same add/remove row pattern for entering multiple order line items without a full data-table.
SURVEY
Survey/Application Repeatable Sections
Collect a variable number of similar entries — work history, references, project links — in an application form.
Related: GDPR Data Request Form
See the GDPR Data Request Form for a related forms pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

No — refreshRemoveButtons() disables the remove button on the last remaining row whenever exactly one row is left, so it is structurally impossible to reduce the form to zero input rows through the UI.

No — the submit handler re-queries rowsEl.querySelectorAll('.fa-input') at submit time, so it always validates however many rows currently exist, regardless of how many were added or removed since the page loaded.

Every field is checked and gets an .invalid style if it fails validation, but only the first invalid field in DOM order receives keyboard focus, so the user is guided directly to the earliest problem rather than needing to scan the whole form.

A row's position in the DOM shifts whenever an earlier row is removed, so using position alone as an identifier could cause two different rows to be treated as "the same" field across add/remove operations. An ever-incrementing counter guarantees every row keeps a stable, unique identity for its whole lifetime.

Replace the demo's statusEl.textContent success message with an actual fetch/XHR call, building the request body from Array.from(rowsEl.querySelectorAll('.fa-input')).map(i => i.value) once validation passes.

Yes — extend createRow() to build and append multiple inputs (and their own labels) inside each .fa-row instead of just one, and update the submit-time validation loop to check each row's full set of fields together.