Skip to content
Brok UI

Loading…

No results

Tags Input

Open source

Token/chip input that lets users add, remove and paste comma-separated tags with full keyboard and form support, and an email mode for recipients with a flagged chip and message per invalid address.

Version
v1.6.3
Stability
stable
License
MIT
Related
Advanced Select
Combobox
People Picker
Input

Preview

previews.components.tags-input.default.blade.php Blade
<x-ui.tags-input name="tags" :value="['design','laravel']" placeholder="Add a tag…" class="w-80" />

Installation

terminal
php artisan ui:add tags-input

Note

This component ships an Alpine behavior module at resources/js/ui/tags-input.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './tags-input.js';

Registry contract

php artisan ui:add tags-input writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/tags-input.blade.php
  • js resources/js/ui/tags-input.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

tags-input.md
# Brok UI: Tags Input (`tags-input`)

Token/chip input that lets users add, remove and paste comma-separated tags with full keyboard and form support, and an email mode for recipients with a flagged chip and message per invalid address.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add tags-input
```

## Usage

```blade
<x-ui.tags-input name="tags" :value="['design','laravel']" placeholder="Add a tag…" class="w-80" />
```

## Props

- `name` (mixed|null, default `null`) — Form field name; inherited from a wrapping field via @aware or set directly. Each tag submits as name[].
- `value` (array, default `[]`) — Initial list of tags.
- `max` (mixed|null, default `null`) — Maximum number of tags allowed. At the limit a draft is kept, and a visible message linked to the field is shown and announced.
- `placeholder` (string|null, default `null`) — Placeholder text shown in the draft input. Null: the translated "Add tag…", or "Add an email address…" in email mode.
- `separator` (string, default `,`) — Key that, alongside Enter, confirms the current draft as a chip.
- `pattern` (mixed|null, default `null`) — Optional regex a draft must match before it becomes a chip; a non-matching draft stays in the box and is flagged invalid. In email mode it replaces the built-in address check, and a miss becomes a flagged chip.
- `inputmode` (string|null, default `null`) — Virtual keyboard hint forwarded to the draft input, such as email or numeric. Null: text, or email in email mode.
- `type` (text|email, default `text`) — email makes a recipients field: a comma, semicolon, space or new line ends an address, a pasted list splits on all of them, duplicates are matched without case, the draft is added on blur, and an address that is not valid becomes a chip flagged invalid with its own visible and announced message.
- `label` (string|null, default `null`) — Accessible name of the draft field. Null: a <label for> that points at input-id names it, else the placeholder does.
- `inputId` (string|null, default `null`) — The id of the draft field, so a <label for="…"> names it.
- `addOnBlur` (bool|null, default `null`) — Confirms the draft as a chip when focus leaves the field. Null: on in email mode, off in text mode.
- `invalidMessage` (string|null, default `null`) — Message for an invalid chip; :value is the entry. Null: the translated ":value is not a valid email address." (email) or ":value is not valid.".
- `maxMessage` (string|null, default `null`) — Message shown at the limit; :max is the limit. Null: the translated "You can add up to :max addresses." (email) or "You can add up to :max items.".

## Use when

- Use when users must enter freeform information that cannot be reliably selected from a list.
- Collecting an open-ended list of short values, such as skills or labels, as removable chips.
- Collecting email recipients (To, Cc, invitations): use type="email" so a pasted list splits into one chip per address and each invalid address is flagged with its own message.
- Needing paste support so a separator-delimited list adds every token at once.

## Avoid when

- Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
- The options are a fixed, known list to choose from rather than free entry; use select-advanced or combobox with multiple instead.
- Choosing people who are already known (members, contacts) by name and avatar; use people-picker instead.
- A single free-text value; use input instead.

## Anti-patterns

- Using freeform entry for a bounded answer set

## Rules

- Use the `<brok:tags-input>` tag (or `<x-ui.tags-input>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/tags-input
- Registry JSON (files, props, contract): https://brokui.dev/r/open/tags-input.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

long-content.blade.php Blade
{{-- A narrow column with long addresses: a chip truncates instead of
     widening the page, and the messages wrap under the chips. --}}
<div class="w-full max-w-xs">
    <x-ui.tags-input
        type="email"
        name="cc"
        :label="__('Cc')"
        :value="[
            'procurement.department.regional.distribution@northwind-traders.example',
            'ada@example.com',
            'accounts-payable@',
            'grace.hopper@example.com',
        ]"
    />
</div>
recipients.blade.php Blade
{{-- type="email" turns the chips into recipients: a pasted list splits on
     commas, semicolons, spaces and new lines, an address that is not valid
     stays as a flagged chip with its own message, and `max` stops at five.
     Each address posts as recipients[]. --}}
<div class="w-full max-w-md space-y-2">
    <x-ui.label for="recipients-draft">{{ __('To') }}</x-ui.label>
    <x-ui.tags-input
        type="email"
        name="recipients"
        input-id="recipients-draft"
        :value="['ada@example.com']"
        :max="5"
    />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
name mixed | null null Form field name; inherited from a wrapping field via @aware or set directly. Each tag submits as name[].
value array [] Initial list of tags.
max mixed | null null Maximum number of tags allowed. At the limit a draft is kept, and a visible message linked to the field is shown and announced.
placeholder string | null null Placeholder text shown in the draft input. Null: the translated "Add tag…", or "Add an email address…" in email mode.
separator string , Key that, alongside Enter, confirms the current draft as a chip.
pattern mixed | null null Optional regex a draft must match before it becomes a chip; a non-matching draft stays in the box and is flagged invalid. In email mode it replaces the built-in address check, and a miss becomes a flagged chip.
inputmode string | null null Virtual keyboard hint forwarded to the draft input, such as email or numeric. Null: text, or email in email mode.
type text | email text email makes a recipients field: a comma, semicolon, space or new line ends an address, a pasted list splits on all of them, duplicates are matched without case, the draft is added on blur, and an address that is not valid becomes a chip flagged invalid with its own visible and announced message.
label string | null null Accessible name of the draft field. Null: a <label for> that points at input-id names it, else the placeholder does.
inputId string | null null The id of the draft field, so a <label for="…"> names it.
addOnBlur bool | null null Confirms the draft as a chip when focus leaves the field. Null: on in email mode, off in text mode.
invalidMessage string | null null Message for an invalid chip; :value is the entry. Null: the translated ":value is not a valid email address." (email) or ":value is not valid.".
maxMessage string | null null Message shown at the limit; :max is the limit. Null: the translated "You can add up to :max addresses." (email) or "You can add up to :max items.".

Slots

  • default — Unused; the chips render from the value/name state only.

Data slots

Stable hooks for CSS overrides and browser tests.

tags-input tags-input-draft tags-input-error tags-input-errors tags-input-limit tags-input-list tags-input-remove tags-input-status tags-input-tag tags-input-values

Behavior

  • Enter or the configured separator key confirms the current draft as a chip; Backspace on an empty draft removes the last chip.
  • Pasting a separator-delimited list adds every token as a chip in one action.
  • Each tag mirrors to a hidden input name[] when name is set, so the full array submits with the form.
  • ArrowLeft (ArrowRight in RTL) at the start of the draft focuses the last chip's remove button; the arrows walk the chips and past the last one back to the draft. Backspace on a focused chip removes it and focuses the chip before it; Delete removes it and focuses the chip now in its place. Every removal is announced.
  • type="email": a comma, semicolon, space or new line ends an address, a pasted list splits on all of them, a duplicate (in any case) is skipped, blur adds the draft, and an address that is not valid becomes a chip flagged invalid with a visible message linked to the field through aria-describedby.
  • Server validation per entry (name.0, name.1 in the error bag) marks that chip invalid with the server message; the root's data-errors carries it outside the ignored chip region, so every Livewire render that changes it reaches the right chip.
  • Livewire-safe: the chip list and the hidden name[] inputs are drawn by Alpine and carry wire:ignore, and the draft carries wire:ignore.self, so a render of the parent component keeps the chips, the invalid marks and the aria-describedby links; wire:model on the root still syncs both ways.
  • Every add or remove sets the x-modelable array (wire:model, x-model) and dispatches a bubbling change { value } from the root; focus leaving the control dispatches blur on the root, so wire:model.live.blur sends the list then.
  • Livewire: wire:model inside a dialog that the same render opens still draws the chips; the chip lists redraw after each model change, outside the morph.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, behaviorTest.

Guidance

Freeform text input

Collect unpredictable freeform information.

Use when

  • Use when users must enter freeform information that cannot be reliably selected from a list.
  • Collecting an open-ended list of short values, such as skills or labels, as removable chips.
  • Collecting email recipients (To, Cc, invitations): use type="email" so a pasted list splits into one chip per address and each invalid address is flagged with its own message.
  • Needing paste support so a separator-delimited list adds every token at once.

Avoid when

  • Do not choose a freeform field when a constrained choice would reduce errors or cognitive load.
  • The options are a fixed, known list to choose from rather than free entry; use select-advanced or combobox with multiple instead.
  • Choosing people who are already known (members, contacts) by name and avatar; use people-picker instead.
  • A single free-text value; use input instead.

Use instead

  • Radio or checkbox for bounded choices
  • Select or combobox for known options

Anti-patterns

  • Using freeform entry for a bounded answer set
Anatomy
root tags-input-values tags-input-list tags-input-tag tags-input-remove tags-input-draft tags-input-errors tags-input-error tags-input-limit tags-input-status
Theming hooks
tags-input tags-input-draft tags-input-error tags-input-errors tags-input-limit tags-input-list tags-input-remove tags-input-status tags-input-tag tags-input-values

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter
Focus
managed
  • Each chip's remove button carries an aria-label naming the tag being removed, not a bare icon, and adds "(not valid)" for an invalid chip.
  • A draft that fails pattern, or any invalid chip, sets aria-invalid on the draft; each invalid chip's message and the limit message are visible text linked through aria-describedby.
  • Additions of an invalid entry, removals, duplicates and the limit are announced once through a polite role="status" region.
  • An invalid chip is marked by a destructive border, an icon and the message, not by colour alone.
  • aria-describedby on the draft names only ids that exist: your own aria-describedby ids, the wrapping field's hint ({name}-description) when it has one, the field's {name}-error only while the field shows a message for the list itself, and each invalid chip's own message.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="tags-input-{{ $record->id }}">
    <x-ui.tags-input name="tags" :value="['design','laravel']" placeholder="Add a tag…" class="w-80" />
</div>

Validation

Validation support: laravel-error-bag. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:tags-input
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/tags-input.blade.php Blade
{{-- A wrapping <x-ui.field name="…"> shares its name down via @aware so this
     composite self-wires old()/aria-invalid/aria-describedby off the shared
     $errors bag. Inert with no field ancestor and no own `name` —
     fully backward-compatible. --}}
@aware([
    'name' => null,
    'hint' => null,
])

@props([
    'name'        => null,
    'value'       => [],
    'max'         => null,
    // Null: the translated "Add tag…" (text) or "Add an email address…" (email).
    'placeholder' => null,
    'separator'   => ',',
    // Optional regex a draft must match before it becomes a chip (e.g. an email
    // address); a non-matching draft stays in the box and is flagged invalid.
    // In email mode it replaces the built-in address check.
    'pattern'     => null,
    // Null: text, or email in email mode.
    'inputmode'   => null,
    // text: free tags. email: recipients. A comma, semicolon, space or new
    // line ends an address, a pasted list splits on all of them, duplicates
    // are matched without case, and an address that is not valid becomes a
    // flagged chip with its own message instead of staying in the draft.
    'type'        => 'text',
    // Accessible name of the draft field. Null: a <label for="input-id">
    // names it, else the placeholder does.
    'label'       => null,
    // The id of the draft field, so a <label for="…"> names it.
    'inputId'     => null,
    // Confirm the draft when focus leaves the field. Null: on in email mode.
    'addOnBlur'   => null,
    // Message for an invalid chip; ":value" is the address. Null: translated default.
    'invalidMessage' => null,
    // Message once `max` chips are there; ":max" is the limit. Null: translated default.
    'maxMessage'  => null,
])

@php
    $awareName = $name ?? null;
    $fieldName = $awareName ?? $attributes->get('name');
    $type = $type === 'email' ? 'email' : 'text';
    $isEmail = $type === 'email';

    $bag = ($errors ?? null) instanceof \Illuminate\Support\ViewErrorBag ? $errors : null;
    // The submitted name is an array (`name[]`); flag invalid when the bag has an
    // error for the field itself or any indexed child key (`name.0`, `name.1`, …).
    $hasBagError = $bag && filled($fieldName) && ($bag->has($fieldName) || $bag->has($fieldName.'.*'));

    // old() repopulates the chip array after a failed redirect-back; fall back to
    // the passed value (coerced to a list) when there is no flashed input.
    $resolvedTags = filled($fieldName) ? old($fieldName, $value) : $value;
    $resolvedTags = is_array($resolvedTags) ? array_values($resolvedTags) : (array) $resolvedTags;

    // A server message for one entry (`name.1`) belongs to that chip; the
    // script matches the index to the list it holds when the render lands.
    // Without a name, the wire:model target is the error-bag key.
    $errorKey = $fieldName;
    if (blank($errorKey)) {
        foreach ($attributes->getAttributes() as $attributeName => $attributeValue) {
            if (str_starts_with((string) $attributeName, 'wire:model') && is_string($attributeValue)) {
                $errorKey = $attributeValue;
                break;
            }
        }
    }
    $chipErrors = [];
    if ($bag && filled($errorKey)) {
        foreach ($bag->getMessages() as $key => $messages) {
            if (preg_match('/^'.preg_quote((string) $errorKey, '/').'\.(\d+)$/', (string) $key, $match) === 1 && $messages !== []) {
                $chipErrors[(int) $match[1]] = (string) reset($messages);
            }
        }
    }
    // A message for the list itself (not for one entry) keeps the field invalid.
    $fieldError = $bag && filled($errorKey) && $bag->has($errorKey);
    $hasBagError = $hasBagError || $chipErrors !== [];

    // The draft is described by the consumer's own ids, the wrapping field's
    // hint (`{name}-description`) and its error (`{name}-error`). x-ui.field
    // draws the error only for a message on the list itself; chip messages
    // (name.2) have their own ids, so every id here names an element that
    // exists.
    $describedBy = collect(preg_split('/\s+/', trim((string) $attributes->get('aria-describedby', ''))) ?: [])
        ->when(filled($awareName) && filled($hint ?? null), fn ($ids) => $ids->push($awareName.'-description'))
        ->when(filled($awareName) && $bag && filled($bag->first($awareName)), fn ($ids) => $ids->push($awareName.'-error'))
        ->filter()
        ->unique()
        ->implode(' ');

    $max = is_numeric($max) && (int) $max > 0 ? (int) $max : null;
    $placeholder = filled($placeholder) ? (string) $placeholder : ($isEmail ? __('Add an email address…') : __('Add tag…'));
    $inputmode = filled($inputmode) ? (string) $inputmode : ($isEmail ? 'email' : 'text');
    $addOnBlur = $addOnBlur === null ? $isEmail : filter_var($addOnBlur, FILTER_VALIDATE_BOOLEAN);
    $accessibleName = filled($label) ? (string) $label : (filled($inputId) ? null : $placeholder);

    $messages = [
        'invalid' => filled($invalidMessage) ? (string) $invalidMessage : ($isEmail ? __(':value is not a valid email address.') : __(':value is not valid.')),
        'max' => $max === null ? '' : (filled($maxMessage)
            ? str_replace(':max', (string) $max, (string) $maxMessage)
            : ($isEmail ? __('You can add up to :max addresses.', ['max' => $max]) : __('You can add up to :max items.', ['max' => $max]))),
        'duplicate' => __(':value is already in the list.'),
        'removed' => __('Removed :value'),
        'remove' => __('Remove :value'),
        'removeInvalid' => __('Remove :value (not valid)'),
    ];

    $baseId = 'tags-input-'.substr(md5((string) ($inputId ?? $fieldName ?? $placeholder).$type), 0, 8);
@endphp

{{--
    Tags Input (dice-ui port) — a token/chip input. Users type a value and press
    Enter or the configured separator key to confirm it as a chip. Backspace on
    an empty draft removes the last chip. Pasting a separator-delimited list adds
    all tokens at once. When `name` is provided, each tag is mirrored to a hidden
    `<input name="name[]">` so the full array is submitted with the form.

    ArrowLeft (ArrowRight in RTL) at the start of the draft moves into the
    chips' remove buttons; the arrows walk the chips, and Backspace or Delete
    removes the focused one. type="email" makes it a recipients field.
--}}
<div
    {{-- The first tags, the list-level error and the field error id sit in data attributes, so the
         x-data expression stays the same when a Livewire render changes them
         (a changed x-data starts the component again). --}}
    x-data="uiTagsInput({ max: @js($max), separator: @js($separator), pattern: @js($pattern), type: @js($type), addOnBlur: @js($addOnBlur), messages: @js($messages), baseId: @js($baseId) })"
    data-value="{{ json_encode($resolvedTags) }}"
    @if ($fieldError) data-field-error="true" @endif
    @if (filled($describedBy)) data-described-by="{{ $describedBy }}" @endif
    {{-- The chip array is modelable: `x-model` (Alpine) and `wire:model`
         (Livewire binds through the same x-model contract) attach here. --}}
    x-modelable="model"
    data-slot="tags-input"
    data-type="{{ $type }}"
    {{-- Server messages per entry index; a Livewire render updates this attribute. --}}
    data-errors="{{ json_encode((object) $chipErrors) }}"
    @if ($hasBagError) data-invalid="true" @endif
    x-on:focusout="onFocusOut()"
    {{ $attributes->except(['name', 'aria-describedby'])->merge(['class' => 'flex flex-wrap items-center gap-2 rounded-md border border-input bg-background px-2 py-2 focus-within:ring-2 focus-within:ring-ring']) }}
>
    {{-- Hidden form inputs — one per tag, submitted as an array. Alpine draws
         them, so a Livewire render leaves this region alone (wire:ignore). --}}
    @if (filled($fieldName))
        <div hidden wire:ignore data-slot="tags-input-values">
            <template x-for="tag in chips" :key="tag">
                <input type="hidden" name="{{ $fieldName }}[]" :value="tag" />
            </template>
        </div>
    @endif

    {{-- Tag chips. Alpine draws them from the model, so a Livewire render of
         the parent must not morph them away (wire:ignore). Server messages per
         chip still arrive: they travel in the root's data-errors, outside this
         region, and the script re-reads that attribute on every change. --}}
    <ul role="list" wire:ignore data-slot="tags-input-list" class="contents">
        <template x-for="(tag, i) in chips" :key="tag">
            <li
                role="listitem"
                data-slot="tags-input-tag"
                x-bind:data-invalid="isInvalid(tag) ? 'true' : null"
                class="inline-flex min-w-0 max-w-full items-center gap-1 rounded-sm border border-transparent bg-secondary px-2 py-0.5 text-sm text-secondary-foreground data-[invalid=true]:border-destructive data-[invalid=true]:bg-background data-[invalid=true]:text-destructive"
            >
                <svg x-show="isInvalid(tag)" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3 shrink-0">
                    <circle cx="12" cy="12" r="10" /><path d="M12 8v4M12 16h.01" />
                </svg>
                <span class="min-w-0 truncate" x-text="tag"></span>
                <button
                    type="button"
                    data-slot="tags-input-remove"
                    @click="removeTag(i, 'draft')"
                    @keydown.backspace.prevent="removeTag(i, 'before')"
                    @keydown.delete.prevent="removeTag(i, 'here')"
                    @keydown.left.prevent="onChipArrow(i, 'left')"
                    @keydown.right.prevent="onChipArrow(i, 'right')"
                    :aria-label="removeLabel(tag)"
                    class="-my-2 -ms-1 -me-2 flex size-6 shrink-0 items-center justify-center rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring motion-reduce:transition-none"
                >
                    <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="size-3">
                        <path d="M18 6 6 18M6 6l12 12" />
                    </svg>
                </button>
            </li>
        </template>
    </ul>

    {{-- Draft input --}}
    <input
        type="text"
        data-slot="tags-input-draft"
        x-ref="draft"
        @if (filled($inputId)) id="{{ $inputId }}" @endif
        x-model="draft"
        {{-- Alpine owns the invalid state and the described-by list (it links
             each chip message); a Livewire render keeps the attributes. --}}
        wire:ignore.self
        @keydown.enter.prevent="addTag()"
        @keydown.backspace="onBackspace()"
        @keydown="onSeparatorKey($event)"
        @keydown.left="onDraftArrow($event, 'left')"
        @keydown.right="onDraftArrow($event, 'right')"
        @paste="onPaste($event)"
        @blur="onDraftBlur()"
        inputmode="{{ $inputmode }}"
        autocomplete="off"
        autocapitalize="off"
        spellcheck="false"
        placeholder="{{ $placeholder }}"
        @if (filled($accessibleName)) aria-label="{{ $accessibleName }}" @endif
        @if ($hasBagError) aria-invalid="true" @endif
        @if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
        x-bind:aria-invalid="fieldInvalid ? 'true' : null"
        x-bind:aria-describedby="describedByIds"
        class="min-w-[6rem] flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
    />

    {{-- One visible message per invalid chip and the limit, linked to the draft. --}}
    <ul data-slot="tags-input-errors" x-show="invalidTags.length > 0 || atLimit" x-cloak wire:ignore class="basis-full space-y-1 px-1 text-sm text-destructive">
        <template x-for="tag in invalidTags" :key="'error-' + tag">
            <li data-slot="tags-input-error" x-bind:id="errorId(tag)" x-text="errorFor(tag)"></li>
        </template>
        <li data-slot="tags-input-limit" x-show="atLimit" x-bind:id="baseId + '-limit'" class="text-muted-foreground" x-text="messages.max"></li>
    </ul>

    <span data-slot="tags-input-status" role="status" wire:ignore class="sr-only" x-text="announcement"></span>
</div>
resources/js/ui/tags-input.js JS
/**
 * Tags Input behavior — a token/chip input that lets the user type values,
 * confirm them (Enter or comma), remove them (× button or Backspace on empty
 * draft), and paste a separator-delimited list all at once.
 *
 * Keyboard in the chips: ArrowLeft (ArrowRight in RTL) at the start of the
 * draft moves focus to the last chip's remove button; the arrows walk the
 * chips and past the last one back to the draft; Backspace removes the
 * focused chip and moves to the one before it, Delete moves to the one now
 * in its place. Removals and refusals are announced in a polite region.
 *
 * Email mode (`type: 'email'`): a comma, semicolon or space also ends an
 * address, a paste splits on those and new lines, duplicates are matched
 * without case, the draft is added on blur, and an address that fails the
 * check becomes a chip flagged invalid, with a visible message linked to
 * the draft (aria-describedby) instead of staying in the box. A server
 * message per entry comes from the root's `data-errors` { index: message }
 * (name.N in the error bag), which a Livewire render updates. The chips and
 * the hidden inputs carry wire:ignore, so that render never removes them;
 * this observer is how the new messages still reach their chips.
 *
 * Every change dispatches a bubbling `change` { value } from the root, and
 * focus leaving the control dispatches `blur` on the root, so wire:model
 * with .live, .change or .blur sends the list at that moment.
 *
 * Self-registers on `alpine:init` so import order does not matter — import
 * this file once from your bundle (e.g. resources/js/ui/index.js).
 */
const EMAIL = /^[^\s@,;<>()]+@[^\s@,;<>()]+\.[^\s@,;<>()]+$/;

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiTagsInput', (config = {}) => {
        const email = config.type === 'email';
        let observer = null;
        let focusOutTimer = null;
        // The root element, kept from init: a chip's handler runs in the
        // scope of an element that the removal just took out of the page,
        // where $root and $refs no longer resolve.
        let rootEl = null;
        // True while a paste adds several entries: they are announced together.
        let quiet = false;
        // True while a removal moves focus to the next chip: the removed
        // button's focusout is not a leave.
        let moving = false;

        return {
            tags: [],
            draft: '',
            max: config.max ?? null,
            separator: config.separator ?? ',',
            pattern: config.pattern ? new RegExp(config.pattern) : (email ? EMAIL : null),
            messages: config.messages ?? {},
            baseId: config.baseId ?? 'tags-input',
            serverErrors: {},
            serverFieldError: false,
            fieldDescribedBy: null,
            announcement: '',
            limitShown: false,
            // Bumped after the model changes, so the chip lists draw again
            // even when the change arrived inside a Livewire morph.
            chipRevision: 0,

            init() {
                rootEl = this.$root;
                let first = config.tags ?? [];
                try {
                    if (rootEl.dataset.value !== undefined) first = JSON.parse(rootEl.dataset.value);
                } catch {
                    first = [];
                }
                // x-modelable may already have set the bound value.
                if (this.tags.length === 0 && Array.isArray(first)) this.tags = first.map(String);
                this.readServerErrors();
                observer = new MutationObserver(() => this.readServerErrors());
                observer.observe(rootEl, { attributes: true, attributeFilter: ['data-errors', 'data-field-error', 'data-described-by'] });
            },

            destroy() {
                observer?.disconnect();
                observer = null;
                clearTimeout(focusOutTimer);
            },

            /**
             * Modelable value (`x-modelable="model"` on the root): the chip array.
             * `x-model` and Livewire's `wire:model` bind an array property here.
             */
            get model() {
                return this.tags;
            },
            set model(v) {
                if (Array.isArray(v)) {
                    this.tags = v.map(String);
                } else {
                    this.tags = v == null || v === '' ? [] : [String(v)];
                }
                // Livewire can set the model while it morphs a teleported
                // region (a dialog that opens in the same render). Alpine
                // drops the effects that run then, so the lists read
                // `chips` and draw again once the morph is done.
                queueMicrotask(() => {
                    this.chipRevision++;
                });
            },

            /** The chips the lists draw: `tags`, redrawn after each model change. */
            get chips() {
                void this.chipRevision;
                return this.tags;
            },

            /** True while a non-empty draft fails the pattern (text mode). */
            get draftInvalid() {
                const v = this.draft.trim();
                return Boolean(!email && v && this.pattern && !this.pattern.test(v));
            },

            /** Chips that fail the check or carry a server message. */
            get invalidTags() {
                return this.chips.filter((tag) => this.isInvalid(tag));
            },

            get atLimit() {
                return this.limitShown && Boolean(this.max) && this.tags.length >= this.max;
            },

            get fieldInvalid() {
                return this.serverFieldError || this.draftInvalid || this.invalidTags.length > 0;
            },

            get describedByIds() {
                const ids = [this.fieldDescribedBy, ...this.invalidTags.map((tag) => this.errorId(tag))];
                if (this.atLimit) ids.push(`${this.baseId}-limit`);
                const list = ids.filter(Boolean).join(' ');
                return list === '' ? null : list;
            },

            isInvalid(tag) {
                if (Object.prototype.hasOwnProperty.call(this.serverErrors, tag)) return true;
                return email && Boolean(this.pattern) && !this.pattern.test(tag);
            },

            errorFor(tag) {
                if (Object.prototype.hasOwnProperty.call(this.serverErrors, tag)) return this.serverErrors[tag];
                return this.format(this.messages.invalid, tag);
            },

            errorId(tag) {
                return `${this.baseId}-error-${this.tags.indexOf(tag)}`;
            },

            removeLabel(tag) {
                return this.format(this.isInvalid(tag) ? this.messages.removeInvalid : this.messages.remove, tag);
            },

            format(message, value) {
                return String(message ?? '').replace(':value', () => value);
            },

            readServerErrors() {
                let parsed = {};
                try {
                    parsed = JSON.parse(rootEl.dataset.errors || '{}') ?? {};
                } catch {
                    parsed = {};
                }
                // Keys are entry indexes (name.2); the render that brought
                // them validated the list as it is now, so map them to text.
                const errors = {};
                for (const [index, message] of Object.entries(typeof parsed === 'object' ? parsed : {})) {
                    const tag = this.tags[Number(index)];
                    if (tag !== undefined && message) errors[tag] = String(message);
                }
                this.serverErrors = errors;
                this.serverFieldError = rootEl.dataset.fieldError === 'true';
                // The ids of the field's hint and error elements (and the
                // consumer's own), sent only while each element is drawn.
                this.fieldDescribedBy = rootEl.dataset.describedBy || null;
            },

            has(value) {
                return email
                    ? this.tags.some((tag) => tag.toLowerCase() === value.toLowerCase())
                    : this.tags.includes(value);
            },

            /**
             * Adds one value. Returns false when it was refused (limit, or a
             * text-mode pattern miss) so the caller keeps the draft.
             */
            push(value) {
                if (this.has(value)) {
                    this.announce(this.format(this.messages.duplicate, value));
                    return true;
                }
                if (this.max && this.tags.length >= this.max) {
                    this.limitShown = true;
                    this.announce(this.messages.max ?? '');
                    return false;
                }
                if (!email && this.pattern && !this.pattern.test(value)) return false;
                this.tags.push(value);
                if (this.isInvalid(value)) this.announce(this.errorFor(value));
                return true;
            },

            addTag() {
                const v = this.draft.trim();
                if (!v) return;
                const before = this.tags.length;
                if (this.push(v)) {
                    this.draft = '';
                    if (this.tags.length !== before) this.changed();
                }
            },

            /**
             * Removes the chip at `i` and announces it. `focus` says where
             * focus goes: 'draft' (a click on ×), 'before' (Backspace on a
             * chip: the chip before it), 'here' (Delete on a chip: the chip
             * now in its place), or null to leave it (Backspace in the draft).
             */
            removeTag(i, focus = null) {
                const [removed] = this.tags.splice(i, 1);
                if (removed === undefined) return;
                if (Object.prototype.hasOwnProperty.call(this.serverErrors, removed)) {
                    const rest = { ...this.serverErrors };
                    delete rest[removed];
                    this.serverErrors = rest;
                }
                if (!this.max || this.tags.length < this.max) this.limitShown = false;
                this.announce(this.format(this.messages.removed, removed));
                this.changed();
                if (focus === null) return;
                let target = null;
                if (focus === 'before') target = this.tags.length === 0 ? null : Math.max(0, i - 1);
                if (focus === 'here') target = i < this.tags.length ? i : null;
                moving = true;
                this.$nextTick(() => {
                    this.focusChip(target);
                    moving = false;
                });
            },

            /** Focuses the remove button of chip `i`, or the draft when there is none. */
            focusChip(i) {
                const button = i === null ? null : rootEl.querySelectorAll('[data-slot="tags-input-remove"]')[i];
                (button ?? rootEl.querySelector('[data-slot="tags-input-draft"]'))?.focus();
            },

            /** The logical direction of an arrow key: back (towards the first chip) or forward. */
            direction(key) {
                const rtl = getComputedStyle(rootEl).direction === 'rtl';
                const back = rtl ? 'right' : 'left';
                return key === back ? 'back' : 'forward';
            },

            onChipArrow(i, key) {
                if (this.direction(key) === 'back') {
                    if (i > 0) this.focusChip(i - 1);
                    return;
                }
                this.focusChip(i + 1 < this.tags.length ? i + 1 : null);
            },

            onDraftArrow(event, key) {
                const input = event.target;
                if (this.direction(key) !== 'back' || this.tags.length === 0) return;
                if (input.selectionStart !== 0 || input.selectionEnd !== 0) return;
                event.preventDefault();
                this.focusChip(this.tags.length - 1);
            },

            onBackspace() {
                if (this.draft === '' && this.tags.length) {
                    this.removeTag(this.tags.length - 1);
                }
            },

            onPaste(e) {
                e.preventDefault();
                const text = e.clipboardData?.getData('text') ?? '';
                const sep = email
                    ? /[,;\s]+/
                    : (this.separator === ',' ? /[,\n\t]/ : new RegExp('[' + this.separator + '\n\t]'));
                const before = this.tags.length;
                const refused = [];
                // One announcement for the whole paste, not one per entry.
                quiet = true;
                text.split(sep).forEach((part) => {
                    const v = part.trim();
                    if (!v) return;
                    if (refused.length > 0 || !this.push(v)) refused.push(v);
                });
                quiet = false;
                this.draft = refused.join(email ? ', ' : this.separator);
                const invalid = this.tags.slice(before).filter((tag) => this.isInvalid(tag));
                if (invalid.length > 0) this.announce(invalid.map((tag) => this.errorFor(tag)).join(' '));
                else if (refused.length > 0 && this.max && this.tags.length >= this.max) this.announce(this.messages.max ?? '');
                if (this.tags.length !== before) this.changed();
            },

            onSeparatorKey(event) {
                if (event.isComposing) return;
                const keys = email ? [this.separator, ',', ';', ' '] : [this.separator];
                if (keys.includes(event.key)) {
                    event.preventDefault();
                    this.addTag();
                }
            },

            onDraftBlur() {
                if (config.addOnBlur) this.addTag();
            },

            /** Focus left the control: tell wire:model.blur and .live.blur. */
            onFocusOut() {
                if (moving) return;
                clearTimeout(focusOutTimer);
                focusOutTimer = setTimeout(() => {
                    if (rootEl.contains(document.activeElement)) return;
                    rootEl.dispatchEvent(new FocusEvent('blur'));
                }, 0);
            },

            changed() {
                rootEl.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: [...this.tags] } }));
            },

            /** Announces once through the polite status region. */
            announce(message) {
                if (quiet) return;
                this.announcement = '';
                if (!message) return;
                this.$nextTick(() => {
                    this.announcement = message;
                });
            },
        };
    });
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.6.3
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0