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”).

CategoryForms
TypeWeb Component (<mv-tag-input>)
Statusbeta
Keywordsexclusive, 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/marvelous

AI 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

NameTypeDefaultDescription
namestringForm field name. Without separator, each tag is submitted as its own name entry (like a multiple select).
valuestringInitial tags, split on commas, semicolons, line breaks, tabs (and separator); also the value restored by form reset.
separatorstringSubmit one value with the tags joined by this string (e.g. “,” or “;”) instead of repeated entries; it also splits typed and pasted text.
maxnumberMaximum number of tags; extra text stays in the field and “Limit reached” is announced.
patternregexEach tag must fully match (like input pattern). Mismatches are still added, flagged visually and as “(invalid)”, and make the field invalid (patternMismatch).
uniquebooleanNo duplicates, compared without case or accents (“cafe” = “Café”); the existing chip flashes instead.
pendingcommit | keepcommitWhat happens to typed text when focus leaves or the form submits: it becomes a tag (commit) or stays in the field (keep).
placeholderstringAdd a tag…Shown while there are no tags (defaults to strings.placeholder).
required / disabledbooleanStandard form behavior: valueMissing with no tags; disabled (also from a disabled fieldset) makes the field and chips inert.
data-count / data-full / data-invalidset by the componentTag count, limit reached, at least one invalid tag (for styling).

Properties

NameTypeDescription
tagsstring[]Current tags (a copy). Setting it replaces them silently, like a native field value. Can be set before the element is defined.
valuestringTags joined with separator (default “, ”); setting accepts a string or an array.
suggestionsstring[]Suggestions shown in the native datalist popup (already added ones are hidden); picking one adds it. A <datalist> child works too.
stringsobjectOverrides 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 / willValidateread-onlyStandard form-control properties.

Methods

NameDescription
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

NameDescription
mv-addCancelable, before a user-added tag is created; detail = { value }. Cancel to veto (the text returns to the field).
mv-removeCancelable, before a tag is removed; detail = { value, index }.
mv-changeAfter tags were added or removed by the user; detail = { tags, value }. A native change event fires on the element too.

Content structure

NameDescription
datalistOptional <datalist> child with <option value> suggestions.

CSS classes

NameDescription
mv-tag-input-listThe <ul role=list> of chips (display: contents, so chips and field share one wrapping row).
mv-tag-input-chip / -text / -removeA chip (li; data-invalid, data-flash), its label and its remove button.
mv-tag-input-fieldThe text input.

CSS variables

NameDefaultDescription
--mv-tag-input-chip-bgvar(--mv-bg-muted)Chip background.
--mv-tag-input-chip-fgvar(--mv-fg)Chip text.
--mv-tag-input-flashvar(--mv-accent)Highlight of the existing chip when a duplicate is typed.
--mv-tag-input-invalidvar(--mv-danger)Invalid chip and field color.
--mv-input-radius / --mv-input-bgvar(--mv-radius-md) / field backgroundShared 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.