A web component that enables you to control the duplication of fields.
npm install @aarongustafson/form-repeatable 
A single-instance web component that manages repeatable form field groups internally using shadow DOM and native form participation via the ElementInternals API.
- 🔄 Manage repeatable field groups - single component instance manages all groups
- 🔢 Auto-increment numeric values in labels, IDs, and for attributes
- ❌ Add and remove groups dynamically with min/max constraints
- 📋 Native form participation - uses ElementInternals API for seamless form integration
- 🎨 Flexible templating - use first child or explicit element
- 🌍 Global styles - automatically adopts page stylesheets into shadow DOM
- 🎯 Modern CSS Grid layout - default 2-column layout with subgrid support
- 📦 Zero dependencies - pure web component
- ♿ Accessible - semantic HTML with proper ARIA labels
- 🎯 TypeScript-ready with type definitions
- Bundled .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).
- Global HTMLElementTagNameMap augmentation lets TypeScript understand document.querySelector('form-repeatable') without additional type casts.
Additional Examples:
- unpkg CDN (Source)
- esm.sh CDN (Source)
``bash`
npm install @aarongustafson/form-repeatable
Import the class and define the custom element with your preferred tag name:
`javascript
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:
`javascript`
import '@aarongustafson/form-repeatable/define.js';
If you prefer to control when the element is registered, call the helper directly:
`javascript
import { defineFormRepeatable } from '@aarongustafson/form-repeatable/define.js';
defineFormRepeatable();
`
You can also include the guarded script from HTML:
`html`
`html`
The component uses the first child as a template. When the user clicks "Add Another":
1. Clones the template
2. Increments numbers ("Stop 1" → "Stop 2", stop-1 → stop-2)
3. Appends the new group to the shadow DOM
4. Shows remove buttons when more than the minimum groups exist
5. Updates the form value via ElementInternals
You can provide multiple initial groups that will be moved into the shadow DOM:
`html`
All child elements become groups managed by the single component instance.
Each group can contain multiple related fields:
`html
`
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 element with {n} placeholders:
`html`
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. |
`html`
This creates a component that:
- Starts with 1 group (will allow adding until min is met)
- Cannot have fewer than 2 groups
- Cannot have more than 5 groups
- Uses custom button labels
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 } |
`javascript
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 |
`css
/ 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:
- Two columns: minmax(min-content, 2fr) for content, minmax(min-content, 1fr) for controlssubgrid
- Subgrid: Each group uses to align with the parent grid
- Column 1: Group content (fields, labels, etc.)
- Column 2: Remove buttons (aligned to the right)
- Below groups: Add button appears after all groups
- Global styles: Page stylesheets are automatically adopted into the shadow DOM
You can override the grid layout using ::part(groups) as shown above.
The component uses Shadow DOM with adopted stylesheets. You can style it using:
1. CSS parts (shown above) - style internal shadow DOM elements
2. Global stylesheets - automatically adopted into shadow DOM, so page styles apply
3. Host element - style the component container itself
`css
/ 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:
- Automatic submission: All inputs within groups are collected and submitted with the form
- FormData integration: Values are added to FormData automatically
- Form reset: Supports native form reset functionality
- Disabled state: Respects form disabled state
- Browser support: 95% (Chrome 77+, Firefox 93+, Safari 16.4+)
The component is form-associated (static formAssociated = true) and manages form values internally using attachInternals().
The component uses a single-instance architecture:
1. Template extraction: Parses the first child element (or explicit ) to create a reusable template/(.)(\d+)(.)/
2. Number detection: Identifies numeric patterns using regex and converts to {n} placeholders{n}
3. Group initialization: Moves all light DOM children into shadow DOM as initial groups
4. Adding groups: Clones the template, replaces with sequential numbers, appends to shadow DOM
5. Removing groups: Removes the group and renumbers remaining groups sequentially (1, 2, 3...)
6. Form value sync: Collects all inputs from groups and updates FormData via ElementInternals
7. Global styles: Automatically adopts page stylesheets into shadow DOM for consistent styling
This component uses modern web standards:
- Custom Elements v1
- Shadow DOM v1
- ElementInternals API
- Adopted Stylesheets
- CSS Grid & Subgrid
- ES Modules
Supported browsers:
- Chrome/Edge 117+ (for subgrid support)
- Firefox 71+ (for subgrid support)
- Safari 16.4+ (for ElementInternals support)
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.
`bashInstall dependencies
npm install
MIT © Aaron Gustafson