Skip to content

Tags — the cross-framework headless token / tags input

Tags is a headless, fully-accessible tags / token input with no third-party engine behind it. It covers the whole behaviour surface: type-to-add with configurable delimiter keys, paste-to-bulk-add, Backspace-deletes-previous, dedup, per-token validation, a max cap, removable chips with labelled remove controls, a live token count, and the focus choreography. The same component ships for React, Vue, Svelte, Angular, Solid, and Lit.

The foundation is the platform itself: one native <input> for typing plus a row of removable chips. The browser keyboard, the clipboard (paste), and focus all come from the platform. The committed tokens are modelValue (the sole model: true prop), so the value is fully two-way bound; the only local state is the in-progress draft text in the input, a genuine UI buffer distinct from the committed list. Rozie owns the author-side API: the two-way r-model:modelValue, the commit / dedup / validate / cap logic, paste distribution, the Backspace behaviour, the focus choreography (via one container ref, never per-chip refs), and the token-themed skin.

Every visual value is a CSS custom property, so the input re-skins to any design system, with ready-made bridges for shadcn/ui, Material 3, and Bootstrap 5.

The @rozie-ui/tags packages

Tags ships as six pre-compiled, per-framework packages. Install the one for your framework; there is no build step and no Rozie toolchain to set up:

PackageInstallREADME
@rozie-ui/tags-reactnpm i @rozie-ui/tags-reactreact/README
@rozie-ui/tags-vuenpm i @rozie-ui/tags-vuevue/README
@rozie-ui/tags-sveltenpm i @rozie-ui/tags-sveltesvelte/README
@rozie-ui/tags-angularnpm i @rozie-ui/tags-angularangular/README
@rozie-ui/tags-solidnpm i @rozie-ui/tags-solidsolid/README
@rozie-ui/tags-litnpm i @rozie-ui/tags-litlit/README

Each package carries only its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common + @angular/forms, solid-js, or lit + @lit-labs/preact-signals + @preact/signals-core).

Quick start

Two-way bind modelValue (a string[]) and type — press Enter or comma to commit a token. Paste a comma-separated list to bulk-add; Backspace in an empty input removes the last token:

rozie
<components>
{
  Tags: './Tags.rozie',
}
</components>

<data>
{
  skills: ['rozie', 'vue'],
}
</data>

<template>
  <Tags
    r-model:modelValue="$data.skills"
    placeholder="Add a skill…"
    ariaLabel="Skills"
    :max="8"
    @add="onAdd"
  />
</template>

r-model:modelValue is Rozie's two-way bind: the consumer hands Tags an array, Tags writes a fresh array back on every add/remove, and the framework reconciler picks it up with no onChange → setState wiring. Because modelValue is the component's sole model: true prop, the Angular output additionally implements ControlValueAccessor, so a Tags is a form control ([formControl] / [(ngModel)] bind directly).

Custom chip rendering

Every chip is rendered through a scoped #tag slot whose params are { tag, index, remove }. The default fallback renders the built-in chip (a label + a labelled remove button); override the slot to render anything — a pill, an avatar, a status dot — and call remove() from your own control:

rozie
<template>
  <Tags r-model:modelValue="$data.skills" ariaLabel="Skills">
    <template #tag="{ tag, remove }">
      <span class="my-pill">{{ tag }} <button type="button" @click="remove">×</button></span>
    </template>
  </Tags>
</template>

On React the slot surfaces as a render-prop children callback — the one documented cross-framework slot divergence.

API

The full prop / event / handle / slot surface lives on the dedicated API reference page: the props (modelValue, delimiters, allowDuplicates, max, disabled / readonly, validate, placeholder, ariaLabel), the add / remove / change events, the clear() / focus() imperative handle, and the scoped tag slot.

Behaviour

InteractionResult
type a charactermirrors into the inline draft buffer (not yet committed).
a delimiter key (Enter / , by default)commits the draft as a token (after trim → validate → dedup → max), clears the draft, and fires add + change.
pastethe pasted text is split on the non-Enter delimiter characters and each piece is bulk-added (same validate/dedup/cap rules); fires add per accepted token.
Backspace in an empty inputremoves the previous (last) token and fires remove + change.
a chip's remove control (click)removes that token and fires remove + change.
reaching maxthe input is disabled; further adds (type-commit, paste, programmatic) are rejected.

A candidate that is empty, a duplicate (when allowDuplicates is false), rejected by validate, or over max is silently dropped — no event fires.

Theming

Every value the component renders is a --rozie-tags-* CSS custom property with a built-in fallback, so it works with zero configuration yet is completely re-skinnable. Override tokens at any ancestor scope:

css
.rozie-tags {
  --rozie-tags-accent: #16a34a;
  --rozie-tags-chip-bg: #dcfce7;
  --rozie-tags-radius: 0.75rem;
  --rozie-tags-gap: 0.5rem;
}

Only cosmetic values flow through tokens; the structural rules compile per-leaf and are not consumer-overridable.

The complete token table and the design-system bridges live on the dedicated theming page.

Accessibility

  • The container is a role="group" with the ariaLabel you supply as its aria-label; the inline text input carries the same label so assistive tech announces what is being entered.
  • Each chip's remove control is a real <button> with an aria-label of "Remove <token>", so it is reachable and announced individually.
  • A visually-hidden aria-live="polite" region announces the current token count ("3 tags") as the list changes.
  • disabled disables the input and every remove button; readonly hides the input and remove buttons so the tokens read as a display of committed values.
  • Focus choreography reads a single container ref and walks root.querySelector('input') — which reaches the input inside Lit's shadow root too — and runs only in post-mount handlers, so it is identical on all six targets.

See also

Pre-1.0 — APIs may change between minor versions.