Tags Input
stimeo--tags-input
Free-input chips: type and press Enter or a delimiter to add removable tags.
The stimeo--tags-input controller turns a text input into a tags / chips field. Enter or the configured delimiter commits the trimmed input as a tag; empty, duplicate (unless allowDuplicates), and over-limit (max) additions are rejected with stimeo--tags-input:reject. Tags render from a template, each with a Remove {label} button, and a fields container mirrors the set as named hidden inputs for form submission. The remove buttons are one roving Tab stop: ArrowLeft/ArrowRight move between them (right past the end returns to the input), Delete/Backspace delete the focused tag, and Backspace on an empty input deletes the last. Removing a tag moves focus to a neighbor, else back to the input. Every change dispatches stimeo--tags-input:change.
Keyboard
| Key | Action |
|---|---|
| Enter / Delimiter | Commit the typed text as a tag. |
| Backspace (empty input) | Delete the last tag. |
| ← / → | Move between tag remove buttons (right past the end returns to input). |
| Delete / Backspace (on a tag) | Delete the focused tag. |
<%# Markup for the tags-input demo.
Enter or a comma (delimiter) turns the input value into a tag, blocking duplicates /
empties / over-limit. Tag remove buttons move via roving (left/right keys), and
Backspace on an empty input deletes the previous tag. The library handles tag
create/remove, hidden-field sync, focus handoff, and live announcements. %>
<div class="tags-input" data-controller="stimeo--tags-input"
data-stimeo--tags-input-delimiter-value=","
data-stimeo--tags-input-name-value="frameworks[]">
<ul
class="tags-input__tags"
role="list"
aria-label="<%= t("components.tags_input.demo.tags_label") %>"
data-stimeo--tags-input-target="tags"></ul>
<input
type="text"
class="tags-input__input"
aria-label="<%= t("components.tags_input.demo.input_label") %>"
aria-describedby="tags-input-help"
data-stimeo--tags-input-target="input"
data-action="keydown->stimeo--tags-input#onKeydown" />
<span id="tags-input-help" class="tags-input__help">
<%= t("components.tags_input.demo.help") %>
</span>
<span
role="status"
aria-live="polite"
class="tags-input__status visually-hidden"
data-stimeo--tags-input-target="status"></span>
<%# The library creates and syncs the submit hidden inputs as name="frameworks[]". %>
<div hidden data-stimeo--tags-input-target="fields"></div>
<template data-stimeo--tags-input-target="tagTemplate">
<li class="tags-input__tag" role="listitem" data-stimeo--tags-input-target="tag">
<span data-tags-input-slot="label"></span>
<%# Removal is handled by a delegated listener on the tags container, so it
works instantly without waiting on Stimulus wiring a data-action onto the
dynamically-added button. %>
<button
type="button"
class="tags-input__remove"
tabindex="-1">×</button>
</li>
</template>
</div>
/*
* Presentation-only styles for the tags-input demo.
* The library handles creating/removing tags, syncing the hidden field, and
* roving (tabindex). data-stimeo--tags-input-full (set when max is reached) is
* the hook used here to dim the input.
*/
.tags-input {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
max-width: 28rem;
padding: 0.4rem;
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
background: var(--surface, var(--surface-card));
}
.tags-input__tags {
display: contents;
margin: 0;
padding: 0;
list-style: none;
}
.tags-input__tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
background: var(--vital-100);
color: var(--vital-800);
font-size: 0.8125rem;
}
.tags-input__remove {
display: inline-flex;
border: 0;
background: transparent;
color: inherit;
font: inherit;
line-height: 1;
cursor: pointer;
}
.tags-input__remove:focus-visible {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: 2px;
border-radius: 50%;
}
.tags-input__input {
flex: 1 1 8rem;
min-width: 8rem;
border: 0;
background: transparent;
font: inherit;
color: var(--fg, var(--color-text));
}
.tags-input__input:focus {
outline: none;
}
.tags-input__help {
flex-basis: 100%;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.tags-input[data-stimeo--tags-input-full] .tags-input__input {
opacity: 0.5;
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--tags-input"
Targets
| Name | Description | Attribute |
|---|---|---|
input
required
|
The text field where the user types tags before committing them. | data-stimeo--tags-input-target="input" |
tags
required
|
The chip list container; delegates removal/navigation for its chips. | data-stimeo--tags-input-target="tags" |
tag
|
An individual rendered chip; its data-value holds the tag value. |
data-stimeo--tags-input-target="tag" |
tagTemplate
|
The <template> cloned to build each chip. |
data-stimeo--tags-input-target="tagTemplate" |
status
|
The aria-live region announcing added/removed tags to assistive tech. |
data-stimeo--tags-input-target="status" |
fields
|
Container that mirrors the tag set as named hidden inputs for submission. |
data-stimeo--tags-input-target="fields" |
Values
| Name | Description | Attribute |
|---|---|---|
delimiter
|
Key that commits the input as a tag, alongside Enter (default ,). |
data-stimeo--tags-input-delimiter-value |
max
|
Maximum number of tags; 0 (default) means unlimited. | data-stimeo--tags-input-max-value |
allowDuplicates
|
When true, permits duplicate tag values (default false). | data-stimeo--tags-input-allow-duplicates-value |
name
|
The name for the generated hidden inputs (default tags[]). |
data-stimeo--tags-input-name-value |
Actions
| Name | Description | Action |
|---|---|---|
onKeydown
|
Commits on Enter/delimiter, deletes the last tag on empty Backspace, and ArrowLeft enters the chip list. | stimeo--tags-input#onKeydown |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched whenever the tag set changes, with the tags array in detail. |
stimeo--tags-input:change |
reject
|
Dispatched when an addition is rejected, with the value and reason (empty/duplicate/max) in detail. | stimeo--tags-input:reject |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
tabindex |
Tag remove button | Roving: active=0, others=-1 (the chip list is one Tab stop). |
data-stimeo--tags-input-full |
Controller element | Present once max is reached (a hint to suppress input). |
text |
Status | Live-region notice of the changed tag. |