Skip to content

Tags input

The base is a plain .re-input with data-re-tags-input. Without JavaScript it’s a normal text field that submits one comma-separated value. Opt into the editor:

import { enhanceTagsInput } from "@relements/core/behaviors/tags-input";
enhanceTagsInput(document);

Enhancing wraps the input in a role="group" shell: typing + Enter or comma commits a chip (reusing the tag visuals), Backspace on an empty field removes the last, and each tag is backed by a hidden input so the form submits an array (getAll(name)). The editor itself is unnamed, so it never contributes a stray value. Commits/removals fire a bubbling re-tags-change event with detail.values.

Options via data-*: data-re-tags-max, data-re-tags-name (the hidden-input name; defaults to the input’s), data-re-tags-allow-duplicates, data-re-tags-tone. Duplicates are rejected case-insensitively by default.

In a framework? Use the <re-tags-input> element or container mode below — both inject the chips without moving the input, so a vdom re-render can’t throw.

Press Enter or comma to add. Backspace removes the last.
Live example
<div class="re-field">
  <label class="re-field__label" for="tags-basic">Tags</label>
  <input
    class="re-input"
    type="text"
    id="tags-basic"
    name="tags"
    value="design, engineering"
    placeholder="Add tags, separated by commas"
    autocomplete="off"
    data-re-tags-input
  />
  <span class="re-field__hint">Press Enter or comma to add. Backspace removes the last.</span>
</div>

At the limit, further entries are rejected and the field is marked invalid.

Live example
<div class="re-field">
  <label class="re-field__label" for="tags-max">Skills</label>
  <input
    class="re-input"
    type="text"
    id="tags-max"
    name="skills"
    value="html, css"
    placeholder="Up to 3"
    autocomplete="off"
    data-re-tags-input
    data-re-tags-max="3"
  />
</div>

Submitting sends one name entry per tag.

Live example
<form>
  <div class="re-field">
    <label class="re-field__label" for="tags-form">Labels</label>
    <input
      class="re-input"
      type="text"
      id="tags-form"
      name="labels"
      value="bug, docs"
      autocomplete="off"
      data-re-tags-input
    />
  </div>
  <button class="re-button" type="submit" style="margin-block-start: var(--re-space-3)">
    Submit
  </button>
</form>

enhanceTagsInput on a bare <input> (above) reparents it into the chip container. That’s ideal for plain HTML, but in a vdom framework that owns the input, a re-render touching its slot can throw insertBefore … not a child. Two forms never move the input:

  • <re-tags-input> (recommended) — the editor as a custom element. It owns its light DOM, so React/Vue/Svelte/Angular treat it as opaque. Give it an aria-label (it has no <label>); .values reads the committed tags and .clear() empties them. The inner <input> is the no-JS fallback.

    <re-tags-input name="tags" value="design, eng" aria-label="Tags">
    <input class="re-input" type="text" placeholder="Add tags" />
    </re-tags-input>
    <script type="module">
    import "@relements/core/elements/re-tags-input";
    </script>
  • Container mode — put data-re-tags-input on a container that wraps the input (instead of on the input itself); the behavior adopts the container as the group and injects the chips as siblings, leaving the input where the framework rendered it.

See the framework guide for the underlying reason.

  • Keyboard — the editor is a native text field, so it focuses with Tab and types normally. Enter or a typed/pasted comma commits the current text as a chip; Backspace on an empty field removes the last chip. Each chip’s × is a real <button> (Tab to reach it, Enter/Space to activate); removing a chip by its button moves focus to the previous chip’s remove button, or back to the editor when none remain.
  • Focus — the whole group shows the visible focus ring on :focus-within (the editor sheds its own ring so the shell owns it); chip remove buttons get the same ring on :focus-visible. At the data-re-tags-max limit the group is marked [data-invalid] and the ring turns danger-colored.
  • Semantics — enhancing wraps the input in a role="group" labelled from the field’s <label for> via aria-labelledby, so AT announces the group by its label, and the editor is aria-describedby the field’s .re-field__hint so the “Press Enter to add” instructions reach AT (not just sighted users). The chips sit in a role="list" (each chip a listitem) so they can be reviewed as a list. Each × button carries an aria-label="Remove <tag>", and a visually hidden (.re-sr-only) aria-live="polite" region announces “Added …”, “Removed …”, “Maximum N tags reached”, and ”… is already added” as you edit (cleared then re-set so an identical repeat still speaks). Committed tags are backed by hidden inputs that carry the submitted array values.
  • Notes — pre-seeded tags are not announced on enhance, only edits you make. Without JavaScript the base degrades to a plain labelled .re-input. See the accessibility guide.