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") %>
<%# Tags the demo starts with, so the list is not empty on arrival and the hidden
fields already carry a value. Framework names are product names, not copy, so
they are written here rather than in the locales. %>
<% initial_tags = %w[Rails Hotwire] %>
<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">
<%# Same shape the tagTemplate clones; the controller reads data-value and
seeds the roving tabindex from these on connect. %>
<% initial_tags.each do |tag| %>
<li class="tags-input__tag" role="listitem"
data-stimeo--tags-input-target="tag" data-value="<%= tag %>">
<span data-stimeo--tags-input-target="label"><%= tag %></span>
<button
type="button"
class="tags-input__remove"
tabindex="-1"
aria-label="<%= remove_label.sub("{label}", tag) %>"
data-stimeo--tags-input-target="remove">×</button>
</li>
<% end %>
</ul>
<input
type="text"
class="tags-input__input"
aria-label="<%= t("components.tags_input.demo.input_label") %>"
placeholder="<%= 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>
<%# Readout: what the component just reported. Purely visual — the component
already announces what a screen reader needs, so this line is aria-hidden to
keep it from being read twice. The value is a code fragment (API identifiers
and values), so only the label is translated. %>
<p class="demo-readout" aria-hidden="true">
<span><%= t("catalog.readout.field") %></span>
<span class="demo-readout__value" data-readout
data-empty-text="<%= t("catalog.readout.empty") %>">—</span>
</p>
/*
* 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;
}
// The hidden field mirrors the value, and for a move the user made it emits the
// native `change` a form already listens to — so `stimeo--auto-submit`, form
// validation, or any `change` handler on the `<form>` reacts without knowing what
// this component is. A refresh that follows a morph or a repair is silent, and
// the component reports a value it moved itself — a repair — as
// `stimeo--tags-input:reconcile`. This demo reads both events, so the
// readout follows the user's moves and the component's repairs.
// The page chrome uses some of these controllers too (the nav, the tab strip),
// so everything below is scoped to the demo itself.
const demoStage = document.querySelector(".demo-stage") ?? document;
const fieldReadout = demoStage.querySelector("[data-readout]");
const fieldSources = demoStage.querySelectorAll("[data-stimeo--tags-input-target='fields']");
// An input without a `name` is not a submitted control, so it is left out.
const submitted = () =>
Array.from(demoStage.querySelectorAll("[data-stimeo--tags-input-target='fields'] input"))
.filter((input) => input.name)
.map((input) => `${input.name}=${input.value}`)
.join(" & ") || fieldReadout?.dataset.emptyText || "";
const showSubmitted = () => {
if (fieldReadout) fieldReadout.textContent = submitted();
};
for (const source of fieldSources) source.addEventListener("change", showSubmitted);
demoStage.addEventListener("stimeo--tags-input:reconcile", showSubmitted);
showSubmitted();
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 |
form
|
The form attribute given to the generated hidden inputs; empty leaves it off. |
data-stimeo--tags-input-form-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). |