Exclusivebeta
Tag Input <mv-tag-input>
Chips input for labels, recipients, skills and keywords: Enter, comma or Tab adds, pasting “a, b; c” (or a column copied from a sheet) adds each one, Backspace selects then removes the last chip, arrow keys walk the chips, optional pattern, limit, case- and accent-insensitive dedupe that flashes the existing chip, and native <datalist> suggestions. What others miss: it is a real form field (ElementInternals: repeated name entries or one separator-joined value, reset, back/forward restore, required and pattern validity), typed text is never lost (on blur or submit it becomes a tag, or stays; overflow and vetoed text go back in the field), separators are caught from mobile and IME keyboards and in Arabic and CJK punctuation, and announcements stay short (“research removed, 2 tags”).
| Category | Forms |
|---|---|
| Type | Web Component (<mv-tag-input>) |
| Status | beta |
| Keywords | exclusive, light, tags, chips, tokens, multi-value, recipients, keywords, form-associated, paste |
When to use
- A form collects several free-form values such as tags, keywords, skills or email recipients and must submit them
- Users paste lists from a spreadsheet, an email client or a document and expect each entry to become a tag
- Entries must match a format such as email addresses, with invalid ones kept and flagged rather than dropped
- A filter bar or settings page needs removable keyword chips that work fully from the keyboard
Avoid when
- Values must be picked from a long or remote list with filtering, groups and option rendering → use Combobox instead
- The field is a storefront search with recent searches and product results → use Search Autocomplete instead
- Only a fixed handful of options can be chosen, with nothing typed → use Checkbox instead
Install
node scripts/add.mjs tag-input --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["tag-input"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, components/tag-input/tag-input.js, components/tag-input/tag-input.css.
Usage
Quick start, the smallest working markup:
<mv-tag-input name="tags" value="design, research"></mv-tag-input>Canonical markup, to start from and customize with attributes, data-* and CSS variables:
<form id="mv-tag-input-demo" style="display:grid;gap:1.1rem;width:min(100%,34rem);padding:1.25rem;border:1px solid var(--mv-border);border-radius:var(--mv-radius-xl);background:var(--mv-surface);box-shadow:var(--mv-shadow-sm)">
<div style="display:grid;gap:.4rem">
<label for="mv-tag-demo-topics" style="font-size:.875rem;font-weight:600">Topics</label>
<mv-tag-input id="mv-tag-demo-topics" name="topics" value="design, research" unique max="6" required placeholder="Add a topic…">
<datalist>
<option value="Accessibility"></option>
<option value="Branding"></option>
<option value="Café culture"></option>
<option value="Localization"></option>
<option value="Pricing"></option>
<option value="Typography"></option>
</datalist>
</mv-tag-input>
<small style="color:var(--mv-fg-muted);font-size:.8125rem">Up to 6. Paste “ux, pricing; Café culture” to add several; typing “Design” flashes the existing tag.</small>
</div>
<div style="display:grid;gap:.4rem">
<label for="mv-tag-demo-invite" style="font-size:.875rem;font-weight:600">Invite reviewers</label>
<mv-tag-input id="mv-tag-demo-invite" name="reviewers" separator=";" unique pattern="[^\s@]+@[^\s@]+\.[^\s@]{2,}" placeholder="[email protected]"
value="[email protected]; [email protected]; lucia.fernandez@lumen"></mv-tag-input>
<small style="color:var(--mv-fg-muted);font-size:.8125rem">Emails only, sent as one “;”-separated value. Invalid addresses stay, flagged, so nothing is lost.</small>
</div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:.5rem">
<button class="mv-button" type="submit">Save project</button>
<button class="mv-button" data-variant="ghost" type="reset">Reset</button>
<output id="mv-tag-demo-out" style="flex-basis:100%;min-height:1.2em;font:.75rem/1.5 var(--mv-font-mono);color:var(--mv-fg-muted);overflow-wrap:anywhere"></output>
</div>
</form>
<script type="module">
const form = document.getElementById("mv-tag-input-demo");
const out = document.getElementById("mv-tag-demo-out");
form.addEventListener("submit", (e) => {
e.preventDefault();
const fd = new FormData(form);
out.textContent = [...fd].map(([k, v]) => `${k}=${v}`).join(" · ");
});
form.addEventListener("invalid", () => { out.textContent = ""; }, true);
</script>API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
name | string | Form field name. Without separator, each tag is submitted as its own name entry (like a multiple select). | |
value | string | Initial tags, split on commas, semicolons, line breaks, tabs (and separator); also the value restored by form reset. | |
separator | string | Submit one value with the tags joined by this string (e.g. “,” or “;”) instead of repeated entries; it also splits typed and pasted text. | |
max | number | Maximum number of tags; extra text stays in the field and “Limit reached” is announced. | |
pattern | regex | Each tag must fully match (like input pattern). Mismatches are still added, flagged visually and as “(invalid)”, and make the field invalid (patternMismatch). | |
unique | boolean | No duplicates, compared without case or accents (“cafe” = “Café”); the existing chip flashes instead. | |
pending | commit | keep | commit | What happens to typed text when focus leaves or the form submits: it becomes a tag (commit) or stays in the field (keep). |
placeholder | string | Add a tag… | Shown while there are no tags (defaults to strings.placeholder). |
required / disabled | boolean | Standard form behavior: valueMissing with no tags; disabled (also from a disabled fieldset) makes the field and chips inert. | |
data-count / data-full / data-invalid | set by the component | Tag count, limit reached, at least one invalid tag (for styling). |
Properties
| Name | Type | Description |
|---|---|---|
tags | string[] | Current tags (a copy). Setting it replaces them silently, like a native field value. Can be set before the element is defined. |
value | string | Tags joined with separator (default “, ”); setting accepts a string or an array. |
suggestions | string[] | Suggestions shown in the native datalist popup (already added ones are hidden); picking one adds it. A <datalist> child works too. |
strings | object | Overrides for every visible and announced text: placeholder, hint, remove (“Remove {tag}”), invalidTag, added, addedInvalid, addedMany, removed, duplicate, full, required, mismatch, overflow. Plural entries are objects keyed by Intl.PluralRules category ({ zero?, one, other… }); numbers are formatted with Intl in the nearest lang. |
form / labels / validity / validationMessage / willValidate | read-only | Standard form-control properties. |
Methods
| Name | Description |
|---|---|
add(text | string[]) | Adds tags as the user would (split, dedupe, limit, mv-add veto, announcement); returns the tags added. |
remove(tag | index) | Removes a tag by value (case- and accent-insensitive) or index. |
clear() | Removes every tag (silent). |
checkValidity() / reportValidity() | Standard; the browser bubble points at the text field. |
focus() | Focuses the text field. |
Events
| Name | Description |
|---|---|
mv-add | Cancelable, before a user-added tag is created; detail = { value }. Cancel to veto (the text returns to the field). |
mv-remove | Cancelable, before a tag is removed; detail = { value, index }. |
mv-change | After tags were added or removed by the user; detail = { tags, value }. A native change event fires on the element too. |
Content structure
| Name | Description |
|---|---|
datalist | Optional <datalist> child with <option value> suggestions. |
CSS classes
| Name | Description |
|---|---|
mv-tag-input-list | The <ul role=list> of chips (display: contents, so chips and field share one wrapping row). |
mv-tag-input-chip / -text / -remove | A chip (li; data-invalid, data-flash), its label and its remove button. |
mv-tag-input-field | The text input. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-tag-input-chip-bg | var(--mv-bg-muted) | Chip background. |
--mv-tag-input-chip-fg | var(--mv-fg) | Chip text. |
--mv-tag-input-flash | var(--mv-accent) | Highlight of the existing chip when a duplicate is typed. |
--mv-tag-input-invalid | var(--mv-danger) | Invalid chip and field color. |
--mv-input-radius / --mv-input-bg | var(--mv-radius-md) / field background | Shared with mv-input, so both fields match. |
Accessibility
The host is a form-associated custom element: a <label for> names it (the inner text field is aria-labelledby the label, or takes the host’s aria-label), clicking the label or the empty part of the box focuses the field, and validity messages anchor to the field. Chips are a real list (ul role=list), so screen readers hear “list, 2 items”; each chip has a real button named “Remove design” (the × icon is aria-hidden). Only the text field is in the Tab order: Left arrow at the start of the field, or Backspace in an empty field, moves focus to the last chip’s remove button; Left/Right (mirrored in RTL), Home and End walk the chips, Right past the last one returns to the field; Backspace removes and moves left, Delete and Enter remove and stay in place, Escape or typing a character goes back to the field. A short screen-reader hint on the field says Enter or comma adds and Left arrow reaches the tags. One polite status region announces only results, briefly: “design added”, “3 tags added”, “research removed, 2 tags”, “Design is already added”, “Limit reached: 6 tags”. Invalid chips are flagged by a “!” mark, a wavy underline and the text “(invalid)”, not by color alone, and the field gets aria-invalid. Focus is always visible (the whole chip is outlined when its button has focus); forced colors use system outlines, a dashed border for invalid chips and a CanvasText mark. Chip entry and the duplicate flash scale only with --mv-motion and stop under prefers-reduced-motion or [data-motion=reduce] (the highlight still shows, statically). Enter never submits while text is pending; with an empty field it submits the form as usual. IME composition is respected.