Skip to content
Forms, switch product

Repeater

Repeater widgets are Input Fields that allow the user to enter information as a list given a template. The information will be stored and returned as an array of objects.

repeater.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.repeater('guests', {
addLabel: 'Add Guest',
removeLabel: 'Remove Guest',
label: 'Guest List',
template: [
gui.layouts.grid([
gui.inputs.textInput('guests.items.guest_name', {
label: 'Full Name',
}),
]),
],
}),
];
repeater.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "repeater",
"path": "guests",
"label": "Guest List",
"props": {
"addLabel": "Add Guest",
"removeLabel": "Remove Guest",
"template": {
"kind": "layout",
"type": "grid",
"children": [
{
"kind": "input",
"type": "textinput",
"path": "guests.items.guest_name",
"label": "Full Name"
}
]
}
}
}
]
}

Use this props to customize your Repeater widget.

proptypedescription
templateobjectMANDATORY A template Field with the elements rendered by the repeater
addButtonIconstringCSS class string for the icon displayed in the add button
addLabelstringLabel text for the add button
removeButtonIconstringCSS class string for the icon displayed in the remove button
removeLabelstringLabel text for the remove button
titlestringOptional title displayed at the top of each card

Use the property template to render a tree of Field widgets for each item in the array of the Repeater widget. The path in the Input Fields widgets inside the template will be updated replacing items keyword by the index of the array. For example, users.items.firstName will be replaced by users[0].firstName for the first element and so on.

repeater.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.repeater('guests', {
addLabel: 'Add Guest',
removeLabel: 'Remove Guest',
label: 'Guest List',
template: [
gui.layouts.grid([
gui.inputs.textInput('guests.items.guest_name', {
label: 'Full Name',
}),
]),
],
}),
];
repeater.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "repeater",
"path": "guests",
"label": "Guest List",
"props": {
"addLabel": "Add Guest",
"removeLabel": "Remove Guest",
"template": {
"kind": "layout",
"type": "grid",
"children": [
{
"kind": "input",
"type": "textinput",
"path": "guests.items.guest_name",
"label": "Full Name"
}
]
}
}
}
]
}

Use the property addLabel and removeLabel to set custom labels for the Add and Remove button.

repeater.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.repeater('users', {
addLabel: 'Add new developer',
removeLabel: 'Remove developer',
defaultValue: [
{
firstName: 'John',
lastName: 'Doe',
},
{
firstName: 'Jane',
lastName: 'Doe',
},
],
template: [
gui.layouts.grid([
gui.inputs.textInput('users.items.firstName'),
gui.inputs.textInput('users.items.lastName'),
]),
],
}),
];
repeater.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"kind": "input",
"type": "repeater",
"path": "users",
"defaultValue": [
{
"firstName": "John",
"lastName": "Doe"
},
{
"firstName": "Jane",
"lastName": "Doe"
}
],
"props": {
"addLabel": "Add new developer",
"removeLabel": "Remove developer",
"template": {
"kind": "layout",
"type": "grid",
"children": [
{
"kind": "input",
"type": "textinput",
"path": "users.items.firstName"
},
{
"kind": "input",
"type": "textinput",
"path": "users.items.lastName"
}
]
}
}
}
]
}

Use the array validator’s maxItems to cap the number of rows. The Add button stays enabled, so the user can add a row past the cap and then remove another one. Past the cap the form is invalid, and the repeater shows the maxItems error when validation runs. With the default validateOn, that is when the user adds the row.

repeater.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.repeater('contacts', {
addLabel: 'Add Contact',
removeLabel: 'Delete',
label: 'Emergency Contacts',
validator: {
maxItems: 3,
messages: {
maxItems: 'Add at most 3 contacts',
},
},
uid: 'repeater_limit',
template: [
gui.layouts.grid([
gui.inputs.textInput('contacts.items.phone', {
label: 'Phone Number',
}),
]),
],
}),
];
repeater.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"uid": "repeater_limit",
"kind": "input",
"type": "repeater",
"path": "contacts",
"label": "Emergency Contacts",
"validator": {
"type": "array",
"maxItems": 3,
"messages": {
"maxItems": "Add at most 3 contacts"
}
},
"props": {
"addLabel": "Add Contact",
"removeLabel": "Delete",
"template": {
"kind": "layout",
"type": "grid",
"children": [
{
"kind": "input",
"type": "textinput",
"path": "contacts.items.phone",
"label": "Phone Number"
}
]
}
}
}
]
}

Use the property title to display a static title at the top of each card.

repeater.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.repeater('guests', {
title: 'Guest',
addLabel: 'Add Guest',
removeLabel: 'Remove',
label: 'Guest List',
uid: 'repeater_title',
template: [
gui.layouts.grid([
gui.inputs.textInput('guests.items.guest_name', {
label: 'Full Name',
}),
]),
],
}),
];
repeater.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"uid": "repeater_title",
"kind": "input",
"type": "repeater",
"path": "guests",
"label": "Guest List",
"props": {
"title": "Guest",
"addLabel": "Add Guest",
"removeLabel": "Remove",
"template": {
"kind": "layout",
"type": "grid",
"children": [
{
"kind": "input",
"type": "textinput",
"path": "guests.items.guest_name",
"label": "Full Name"
}
]
}
}
}
]
}

Use the properties addButtonIcon and removeButtonIcon to display an icon inside the add and remove buttons. Icons are passed as a CSS class string, the same way as other icon props in the library.

repeater.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.repeater('guests', {
addLabel: 'Add Guest',
removeLabel: 'Remove',
addButtonIcon: 'add',
removeButtonIcon: 'remove',
label: 'Guest List',
uid: 'repeater_icons',
template: [
gui.layouts.grid([
gui.inputs.textInput('guests.items.guest_name', {
label: 'Full Name',
}),
]),
],
}),
];
repeater.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"uid": "repeater_icons",
"kind": "input",
"type": "repeater",
"path": "guests",
"label": "Guest List",
"props": {
"addLabel": "Add Guest",
"removeLabel": "Remove",
"addButtonIcon": "add",
"removeButtonIcon": "remove",
"template": {
"kind": "layout",
"type": "grid",
"children": [
{
"kind": "input",
"type": "textinput",
"path": "guests.items.guest_name",
"label": "Full Name"
}
]
}
}
}
]
}

Repeaters can be nested inside other repeaters to model hierarchical data structures. Each nesting level adds an additional .items. segment to the path. For example, in a Teams → Members structure:

  • The outer repeater path is teams
  • Fields inside the outer template use teams.items.fieldName
  • The inner repeater path is teams.items.members
  • Fields inside the inner template use teams.items.members.items.fieldName

At runtime, each items token is replaced by the corresponding array index. For instance, teams.items.members.items.memberName becomes teams[0].members[1].memberName for the second member of the first team.

repeater.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.repeater('teams', {
addLabel: 'Add Team',
removeLabel: 'Remove Team',
label: 'Teams',
uid: 'team_repeater',
template: [
gui.layouts.grid([
gui.inputs.textInput('teams.items.teamName', {
label: 'Team Name',
}),
gui.inputs.repeater('teams.items.members', {
addLabel: 'Add Member',
removeLabel: 'Remove Member',
label: 'Members',
uid: 'member_repeater',
template: [
gui.layouts.grid([
gui.inputs.textInput('teams.items.members.items.memberName', {
label: 'Member Name',
}),
]),
],
}),
]),
],
}),
];
repeater.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"uid": "team_repeater",
"kind": "input",
"type": "repeater",
"path": "teams",
"label": "Teams",
"props": {
"addLabel": "Add Team",
"removeLabel": "Remove Team",
"template": {
"kind": "layout",
"type": "grid",
"children": [
{
"kind": "input",
"type": "textinput",
"path": "teams.items.teamName",
"label": "Team Name"
},
{
"uid": "member_repeater",
"kind": "input",
"type": "repeater",
"path": "teams.items.members",
"label": "Members",
"props": {
"addLabel": "Add Member",
"removeLabel": "Remove Member",
"template": {
"kind": "layout",
"type": "grid",
"children": [
{
"kind": "input",
"type": "textinput",
"path": "teams.items.members.items.memberName",
"label": "Member Name"
}
]
}
}
}
]
}
}
}
]
}

For the $item/$index scope variables available inside nested templates, see $item and $index below.

Inside a repeater’s template, every widget can read the current row through two scope variables: $item (the row’s array element, looked up live from form data) and $index (its zero-based position). Both are available in include/exclude/disabled/readonly when expressions, in {{ }} interpolation slots, and in i18n params - anywhere those mechanisms already work, they just gain two more variables to read from.

repeater.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.repeater('lineItems', {
addLabel: 'Add Line Item',
removeLabel: 'Remove Line Item',
label: 'Line Items',
uid: 'line_items_repeater',
template: [
gui.layouts.grid([
gui.inputs.numberInput('lineItems.items.quantity', {
label: 'Quantity',
}),
gui.inputs.currency('lineItems.items.unitPrice', {
label: 'Unit Price',
}),
gui.displays.markdownText({
md: 'Row {{ $index + 1 }} total: {{ (($item.quantity ?? 0) * ($item.unitPrice ?? 0)).toFixed(2) }}',
include: {
when: '$item.quantity !== undefined',
},
}),
]),
],
}),
];
repeater.json
{
"form": [
{
"uid": "line_items_repeater",
"kind": "input",
"type": "repeater",
"path": "lineItems",
"label": "Line Items",
"props": {
"addLabel": "Add Line Item",
"removeLabel": "Remove Line Item",
"template": {
"kind": "layout",
"type": "grid",
"children": [
{
"kind": "input",
"type": "number",
"path": "lineItems.items.quantity",
"label": "Quantity"
},
{
"kind": "input",
"type": "currency",
"path": "lineItems.items.unitPrice",
"label": "Unit Price"
},
{
"kind": "display",
"type": "markdownText",
"props": {
"md": "Row {{ $index + 1 }} total: {{ (($item.quantity ?? 0) * ($item.unitPrice ?? 0)).toFixed(2) }}"
},
"include": {
"when": "$item.quantity !== undefined"
}
}
]
}
}
}
]
}

A freshly added row has no data yet, so $item leaf values can be undefined - guard reads with ?? or ?., as in $item.quantity ?? 0. $index is always a defined number and never needs a guard.

Where you can use $item/$index:

For nested repeaters, $item/$index always resolve to the innermost enclosing repeater’s item. A widget placed directly in an outer template - not inside the inner repeater’s template - sees the outer item instead. Scope follows where a widget sits in the template tree, not “the deepest repeater anywhere in the form.”

repeater.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.repeater('projects', {
addLabel: 'Add Project',
removeLabel: 'Remove Project',
label: 'Projects',
uid: 'projects_repeater',
template: [
gui.layouts.grid([
gui.inputs.textInput('projects.items.projectName', {
label: 'Project Name',
}),
gui.inputs.repeater('projects.items.tasks', {
addLabel: 'Add Task',
removeLabel: 'Remove Task',
label: 'Tasks',
uid: 'tasks_repeater',
template: [
gui.layouts.grid([
gui.inputs.numberInput('projects.items.tasks.items.hours', {
label: 'Hours',
}),
gui.inputs.currency('projects.items.tasks.items.rate', {
label: 'Hourly Rate',
}),
gui.displays.markdownText({
md: 'Task {{ $index + 1 }}: {{ (($item.hours ?? 0) * ($item.rate ?? 0)).toFixed(2) }}',
}),
]),
],
}),
gui.displays.alert({
text: 'Project total: {{ ($item.tasks ?? []).reduce((sum, task) => sum + ((task.hours ?? 0) * (task.rate ?? 0)), 0).toFixed(2) }}',
}),
]),
],
}),
];
repeater.json
{
"form": [
{
"uid": "projects_repeater",
"kind": "input",
"type": "repeater",
"path": "projects",
"label": "Projects",
"props": {
"addLabel": "Add Project",
"removeLabel": "Remove Project",
"template": {
"kind": "layout",
"type": "grid",
"children": [
{
"kind": "input",
"type": "textinput",
"path": "projects.items.projectName",
"label": "Project Name"
},
{
"uid": "tasks_repeater",
"kind": "input",
"type": "repeater",
"path": "projects.items.tasks",
"label": "Tasks",
"props": {
"addLabel": "Add Task",
"removeLabel": "Remove Task",
"template": {
"kind": "layout",
"type": "grid",
"children": [
{
"kind": "input",
"type": "number",
"path": "projects.items.tasks.items.hours",
"label": "Hours"
},
{
"kind": "input",
"type": "currency",
"path": "projects.items.tasks.items.rate",
"label": "Hourly Rate"
},
{
"kind": "display",
"type": "markdownText",
"props": {
"md": "Task {{ $index + 1 }}: {{ (($item.hours ?? 0) * ($item.rate ?? 0)).toFixed(2) }}"
}
}
]
}
}
},
{
"kind": "display",
"type": "alert",
"props": {
"text": "Project total: {{ ($item.tasks ?? []).reduce((sum, task) => sum + ((task.hours ?? 0) * (task.rate ?? 0)), 0).toFixed(2) }}"
}
}
]
}
}
}
]
}

An event from a widget inside a row, such as the click of a menu or the change of a field, carries the row it came from in event.row, so one handler serves every row:

  • index: the row’s position in its repeater, the same value as $index.
  • indexes: the position in every enclosing repeater, outermost first. Its last entry is index.
  • path: the data path of the row, such as team.1, or teams.2.members.1 in a nested repeater.

Outside a repeater, row is undefined. Pick an action in the menu of a row below: the handler reads the row’s data with event.row.index.

repeater.ts
import { gui } from '@golemui/gui-shared';
export default [
gui.inputs.repeater('team', {
addLabel: 'Add member',
removeLabel: 'Remove member',
label: 'Team',
uid: 'team_repeater',
template: [
gui.layouts.grid([
gui.inputs.textInput('team.items.name', {
label: 'Name',
size: 5,
}),
gui.actions.menu({
items: [
{
label: 'Make lead',
value: 'lead',
},
{
label: 'Send invite',
value: 'invite',
},
],
ariaLabel: 'Member actions',
icon: 'more_vert',
variant: 'outlined',
placement: 'bottom-end',
uid: 'memberMenu',
onClick: () => 'repeaterRowAction',
}),
], {
direction: 'row',
}),
],
}),
gui.displays.alert({
text: 'Pick an action in the menu of a row.',
uid: 'rowActionResult',
}),
];
repeater.json
{
"$schema": "https://golemui.com/schemas/form.schema.json",
"form": [
{
"uid": "team_repeater",
"kind": "input",
"type": "repeater",
"path": "team",
"label": "Team",
"props": {
"addLabel": "Add member",
"removeLabel": "Remove member",
"template": {
"kind": "layout",
"type": "grid",
"props": {
"direction": "row"
},
"children": [
{
"kind": "input",
"type": "textinput",
"path": "team.items.name",
"label": "Name",
"size": 5
},
{
"uid": "memberMenu",
"kind": "action",
"type": "menu",
"on": {
"click": "repeaterRowAction"
},
"props": {
"items": [
{
"label": "Make lead",
"value": "lead"
},
{
"label": "Send invite",
"value": "invite"
}
],
"ariaLabel": "Member actions",
"icon": "more_vert",
"variant": "outlined",
"placement": "bottom-end"
}
}
]
}
}
},
{
"uid": "rowActionResult",
"kind": "display",
"type": "alert",
"props": {
"text": "Pick an action in the menu of a row."
}
}
]
}
Handling the row event
function onFormEvent(event: FormEvent) {
if (event.name !== 'repeaterRowAction' || !event.row) return;
const member = event.data.team[event.row.index];
console.log(event.detail.value, member.name, event.row.path);
}

Read row in your formEvent handler, in a menu’s onClick: (event) => ..., or in an input’s onChange: (event) => .... A button’s onClick receives only the form data: return an event name instead, onClick: () => 'rowPicked', and read event.row in formEvent.

A row has state besides its data: its touched state, its errors, the issues your app injects, and the prop overrides your app sets by path. This state belongs to the item in the row, not to its position.

When the user removes a row with the Remove button, the state of the other rows moves with them, also in nested repeaters. Two cases stay at their positions:

  • setData with a new array. The form can’t know which new row was which old row.
  • An error that a widget reports itself, such as a rejected date like 02/31/2026. The rejected text stays in the input at that position, so its error stays next to it.

This prop still works, and v3.0.0 removes it:

DeprecatedUse instead
limitvalidator.maxItems. limit disables the Add button at that number of rows and shows no message

Repeaters can be styled as explained in the Styling Guide.

Following you will find a list with the CSS Variables and a quick description of what you will style.

CSS VariableDescription
--gui-space-gapSpace between each repeater element
--gui-field-gapSpace between Add/Remove buttons
--gui-paddingRepeater element padding
--gui-color-borderRepeater element border color
--gui-radiusRepeater element border radius
--gui-color-bgRepeater element background color
--gui-color-fgRepeater element text color

This is the anatomy of the Repeater Widget in case you want to use your CSS styles.

repeater.html
<gui-repeater-input class="gui-repeater gui-field">
<div id="repeater_uid" class="gui-repeater__main-card gui-repeater__card">
<gui-label>
<span class="gui-label" id="repeater_uid_label">
<span class="gui-label__text">Guest List</span>
</span>
</gui-label>
<div class="gui-repeater__card">
<div class="gui-repeater__card-header">
<span class="gui-repeater__card-title">Guest 1</span>
<button
type="button"
tabindex="0"
class="gui-button gui-button--sm gui-repeater__remove-btn"
>
<span class="gui-widget-icon gui-button-icon delete" data-icon="delete"></span>
Remove Guest
</button>
</div>
<gui-grid-layout class="gui-field">
<div class="gui-grid" id="#0.0.t[0]">
<div class="gui-grid__cell">
<!-- Child widget: Full Name -->
</div>
</div>
</gui-grid-layout>
</div>
<button type="button" tabindex="0" class="gui-button gui-repeater__add-btn">
<span class="gui-widget-icon gui-button-icon person_add" data-icon="person_add"></span>
Add Guest
</button>
</div>
</gui-repeater-input>