A single-instance web component that manages repeatable form field groups internally using shadow DOM and native form participation via the ElementInternals API.
for attributes<template> element.d.ts definitions describe FormRepeatableElement and defineFormRepeatable, so editors and build pipelines get full typing with zero config.addLabel, removeLabel, min, and max now reflect between properties and attributes, keeping declarative templates and reactive frameworks in sync with the DOM._upgradeProperty ensures properties assigned before the browser upgrades the custom element are re-applied once the class is connected (useful for SSR and hydration scenarios).HTMLElementTagNameMap augmentation lets TypeScript understand document.querySelector('form-repeatable') without additional type casts.Additional Examples:
npm install @aarongustafson/form-repeatable
Import the class and define the custom element with your preferred tag name:
import { FormRepeatableElement } from '@aarongustafson/form-repeatable';
customElements.define('my-custom-name', FormRepeatableElement);
Use the guarded definition helper to register the element when customElements is available:
import '@aarongustafson/form-repeatable/define.js';
If you prefer to control when the element is registered, call the helper directly:
import { defineFormRepeatable } from '@aarongustafson/form-repeatable/define.js';
defineFormRepeatable();
You can also include the guarded script from HTML:
<script src="./node_modules/@aarongustafson/form-repeatable/define.js" type="module"></script>
<form>
<form-repeatable>
<div>
<label for="stop-1">Stop 1</label>
<input id="stop-1" type="text" name="stops[]">
</div>
</form-repeatable>
</form>
The component uses the first child as a template. When the user clicks "Add Another":
stop-1 → stop-2)You can provide multiple initial groups that will be moved into the shadow DOM:
<form>
<form-repeatable min="2">
<div>
<label for="phone-1">Phone 1</label>
<input id="phone-1" type="tel" name="phones[]" value="555-0100">
</div>
<div>
<label for="phone-2">Phone 2</label>
<input id="phone-2" type="tel" name="phones[]" value="555-0101">
</div>
<div>
<label for="phone-3">Phone 3</label>
<input id="phone-3" type="tel" name="phones[]">
</div>
</form-repeatable>
</form>
All child elements become groups managed by the single component instance.
Each group can contain multiple related fields:
<form-repeatable>
<fieldset>
<legend>Guest 1</legend>
<label for="guest-name-1">Name</label>
<input id="guest-name-1" type="text" name="guest-name-1">
<label for="guest-email-1">Email</label>
<input id="guest-email-1" type="email" name="guest-email-1">
</fieldset>
</form-repeatable>
All numeric values in labels, IDs, and attributes are incremented when new groups are added.
Instead of using the first child as a template, you can provide an explicit <template> element with {n} placeholders:
<form-repeatable>
<template>
<div>
<label for="email-{n}">Email {n}</label>
<input id="email-{n}" type="email" name="emails[]">
</div>
</template>
</form-repeatable>
The {n} placeholders are replaced with sequential numbers (1, 2, 3, etc.).
| Attribute | Type | Default | Description |
|---|---|---|---|
add-label |
string |
"Add Another" |
Custom label for the "Add" button |
remove-label |
string |
"Remove" |
Custom label for the "Remove" button. The accessible name (aria-label) is automatically composed by combining this with the first label/legend text (e.g., "Remove Stop 1"). |
min |
number |
1 |
Minimum number of groups (must be > 0). Remove buttons are hidden when at minimum. |
max |
number |
null |
Maximum number of groups (optional, must be > min). Add button is hidden when at maximum. |
<form-repeatable min="2" max="5" add-label="Add Team Member" remove-label="Remove Member">
<div>
<label for="member-1">Team Member 1</label>
<input id="member-1" type="text" name="members[]">
</div>
</form-repeatable>
This creates a component that:
The component fires custom events that you can listen to:
| Event | Description | Detail |
|---|---|---|
form-repeatable:added |
Fired when a new group is added | { group: Object, groupCount: number } |
form-repeatable:removed |
Fired when a group is removed | { group: Object, groupCount: number } |
const repeatable = document.querySelector('form-repeatable');
repeatable.addEventListener('form-repeatable:added', (event) => {
console.log('Group added. Total groups:', event.detail.groupCount);
});
repeatable.addEventListener('form-repeatable:removed', (event) => {
console.log('Group removed. Total groups:', event.detail.groupCount);
});
The component exposes CSS parts that allow you to style internal shadow DOM elements:
| Part | Description |
|---|---|
groups |
Container for all groups (default: CSS grid with 2-column layout) |
group |
Each repeatable group wrapper (default: subgrid spanning both columns) |
content |
Container for the group's fields (default: column 1) |
group-controls |
Container for group-level controls, remove button (default: column 2) |
controls |
Container for field-level controls, add button (default: below groups) |
button |
Both buttons (style all buttons together) |
add-button |
The "Add Another" button |
remove-button |
The "Remove" buttons |
/* Style all buttons */
form-repeatable::part(button) {
padding: 0.5rem 1rem;
font-weight: bold;
border: none;
border-radius: 4px;
cursor: pointer;
}
/* Style the add button specifically */
form-repeatable::part(add-button) {
background: #28a745;
color: white;
}
form-repeatable::part(add-button):hover {
background: #218838;
}
/* Style the remove button specifically */
form-repeatable::part(remove-button) {
background: #dc3545;
color: white;
}
/* Style each group */
form-repeatable::part(group) {
padding: 1rem;
background: #f8f9fa;
border-radius: 4px;
margin-bottom: 0.5rem;
}
/* Customize the grid layout */
form-repeatable::part(groups) {
grid-template-columns: 1fr auto; /* Adjust column sizes */
gap: 1rem;
}
The component uses a CSS Grid layout by default:
minmax(min-content, 2fr) for content, minmax(min-content, 1fr) for controlssubgrid to align with the parent gridYou can override the grid layout using ::part(groups) as shown above.
The component uses Shadow DOM with adopted stylesheets. You can style it using:
/* Style the host element */
form-repeatable {
display: block;
margin-bottom: 2rem;
}
/* Global styles automatically apply to elements in shadow DOM */
input,
select,
textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
label {
display: block;
font-weight: bold;
margin-bottom: 0.25rem;
}
This component uses the ElementInternals API for native form participation:
The component is form-associated (static formAssociated = true) and manages form values internally using attachInternals().
The component uses a single-instance architecture:
<template>) to create a reusable template/(.*)(\d+)(.*)/ and converts to {n} placeholders{n} with sequential numbers, appends to shadow DOMThis component uses modern web standards:
Supported browsers:
For broader browser support without subgrid, you can override the default grid layout using CSS parts. For older browsers, you may need polyfills from @webcomponents/webcomponentsjs.
# Install dependencies
npm install
# Run tests
npm test
# Run tests with UI
npm run test:ui
# Run tests with coverage
npm run test:coverage
# Lint code
npm run lint
# Format code
npm run format
# View demo
open demo/index.html
MIT © Aaron Gustafson