Tags Input
stimeo--tags-input
Turns what you type into tags, committed with Enter or a separator and removable afterwards.
Turns what you type into tags. Enter or the separator you choose commits one, trimmed of surrounding spaces. An empty entry, one that is already there, or one past the limit is refused with the reason given, and duplicates can be allowed if you want them. Tags render from your template, so the look is yours and the remove button's name can be written per language. The row of tags is a single Tab stop, moved through with the left and right keys, and Delete removes one. Backspace in an empty field removes the last tag. The finished set is mirrored into hidden fields so your form submits it as usual.
Keyboard
| Key | Action |
|---|---|
| Enter / Delimiter | Commit the typed text as a tag. |
| Backspace (empty input) | Delete the last tag while retaining input focus. |
| ← / → | Move between tag remove buttons (past the end returns to input). Under RTL the two swap. |
| 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 the add / remove announcements
read by the shared stimeo--announcer your app seats once, in its layout (the tag list
owns no live region of its own). %>
<% announce_added = t("components.tags_input.demo.announce_added") %>
<% announce_removed = t("components.tags_input.demo.announce_removed") %>
<% remove_label = t("components.tags_input.demo.remove_label") %>
<div class="tags-input" data-controller="stimeo--tags-input"
data-stimeo--tags-input-delimiter-value=","
data-stimeo--tags-input-name-value="frameworks[]"
data-stimeo--tags-input-announce-text-value="<%= announce_added %>"
data-stimeo--tags-input-announce-removed-text-value="<%= announce_removed %>">
<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>
<%# 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-stimeo--tags-input-target="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"
aria-label="<%= remove_label %>"
data-stimeo--tags-input-target="remove">×</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;
}
/* The tag list and its input read as one control, so the ring frames the whole box
rather than the bare input inside it. */
.tags-input:focus-within {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.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
required
|
The <template> cloned to build each chip. |
data-stimeo--tags-input-target="tagTemplate" |
label
|
The element in the chip template that receives the rendered tag label. | data-stimeo--tags-input-target="label" |
remove
|
The chip remove button; its authored localized aria-label expands {label} and {value}. |
data-stimeo--tags-input-target="remove" |
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 |
announceText
|
Localized addition template for the shared announcer; supports {label}, {value}, and {count}. |
data-stimeo--tags-input-announce-text-value |
announceRemovedText
|
Localized removal template for the shared announcer; supports the same placeholders. | data-stimeo--tags-input-announce-removed-text-value |
Actions
| Name | Description | Action |
|---|---|---|
onKeydown
|
Commits on Enter/delimiter, deletes the last tag while retaining input focus on empty Backspace, and ArrowLeft enters the chip list. | stimeo--tags-input#onKeydown |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched for a user-authored tag addition or removal, with the tags array in detail. |
stimeo--tags-input:change |
reconcile
|
Dispatched when external DOM/Turbo tag changes alter the ordered tag set, with the same detail as change. | stimeo--tags-input:reconcile |
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). |