---
title: Tags Input
description: Tag entry input with add/remove
visibility: guest
draft: false
---

# Tags Input

The `<x-wirekit::tags-input>` component allows free-form tag creation. Users type text and press Enter or comma to create a tag chip. Each chip has a remove button, and the combined values are submitted as an array.

## Basic Usage

:::preview{title="Basic Tags Input"}
<x-wirekit::tags-input label="Tags" name="tags" placeholder="Add a tag..." />
:::

## With Preexisting Tags

:::preview{title="Preset Tags"}
<x-wirekit::tags-input
    label="Technologies"
    name="technologies"
    :value="['Laravel', 'Livewire', 'Alpine.js']"
    placeholder="Add more..."
/>
:::

## With Max Tags

:::preview{title="Maximum 3 Tags"}
<x-wirekit::tags-input
    label="Keywords"
    name="keywords"
    :max-tags="3"
    hint="You can add up to 3 keywords."
/>
:::

When the maximum is reached, the text input is disabled and a screen-reader announcement confirms the limit.

## Error State

:::preview{title="Tags input with an error message"}
<x-wirekit::tags-input label="Tags" name="tags-error-demo" error="At least one tag is required." />
:::

## Width

Like all WireKit form components, the tags input fills its container (`w-full`). Control the width via the parent element:

```blade
<div class="max-w-md">
    <x-wirekit::tags-input label="Skills" name="skills" />
</div>
```

See [Input — Width](/components/input#width) for more layout examples (grid columns, mixed widths).

## Keyboard Behavior

| Key | Action |
| --- | --- |
| Enter | Create tag from current text |
| `,` (comma) | Create tag from current text |
| Backspace | Remove last tag (when input is empty) |
| Escape | Clear current input text |

Duplicate tags are silently ignored — the same tag cannot appear twice.

## Form Submission

Each tag is submitted via a hidden `<input type="hidden" name="name[]">`, so Laravel receives a plain array of strings.

## Optimistic UI

Pass the name of the Livewire method the field should call and the set is sent the moment a tag is added or removed:

```blade
<x-wirekit::tags-input
    name="labels"
    label="Labels"
    :value="$labels"
    optimistic="saveLabels"
/>
```

Load `wirekit-optimistic.js` alongside whichever bundle you already use — below it, in your layout:

```blade
@wirekitScripts
<script src="{{ asset('vendor/wirekit/wirekit-optimistic.js') }}"></script>
```

### Try it

The demo below runs the real path: the change shows immediately, the outline says it is
provisional, and the server's answer either confirms it silently or takes it back.

:::preview{title="Optimistic tags — accepted, refused, and a slow answer"}
<livewire:demos.optimistic-host>
<x-wirekit::stack gap="lg" style="max-width: 26rem;">
    <x-wirekit::tags-input name="opt-accept" label="Accepted — the confirmation is silent" :value="['design']" optimistic="demoAccept" />
    <x-wirekit::tags-input name="opt-reject" label="Refused — the tag stays, because you typed it" :value="['design']" optimistic="demoReject" />
    <x-wirekit::tags-input name="opt-slow" label="Slow answer — the dashed outline is the provisional state" :value="['design']" optimistic="demoSlow" />
</x-wirekit::stack>
</livewire:demos.optimistic-host>
:::

:::source{language="blade"}
{{-- In your app there is no host: your own Livewire component owns the method. --}}
<x-wirekit::tags-input
    name="labels"
    label="Labels"
    :value="$labels"
    optimistic="saveLabels"
/>
:::

The `<livewire:demos.…>` wrapper above exists only on this site — it supplies the demo
methods so the page can show a real round trip. The block under it is what you write.

**A refusal keeps the change rather than undoing it.** A tag is typed, so undoing a failed add would delete what you just wrote. Removal takes the same exit for a structural reason: the set is one value, so a rollback restores the whole set — there is no way to undo a removal without also undoing an addition that happened alongside it. A refused removal therefore stays removed and says so, which is recoverable: the tag is one keystroke away.

The field is **not** marked invalid. `aria-invalid` means *this value is wrong*, and a save that failed on the network says nothing about the tags.

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string\|null` | `null` | Label text above the input |
| `hint` | `string\|null` | `null` | Help text below the input |
| `error` | `string\|null` | `null` | Error message (also reads from `$errors`) |
| `name` | `string\|null` | `null` | Form field name (submits as `name[]`) |
| `id` | `string\|null` | auto-generated | Element id |
| `value` | `array` | `[]` | Initial tags |
| `maxTags` | `int\|null` | `null` | Maximum number of tags (null = unlimited) |
| `placeholder` | `string` | `'Add a tag...'` | Placeholder text in the text input |
| `disabled` | `bool` | `false` | Disabled state |
| `scope` | `string\|null` | `null` | Scoped personalization key |
| `optimistic` | `string\|null` | `null` | Livewire method to call when a tag is added or removed, showing the new set before the server answers. A refusal **keeps the change** and says it was not saved — a rollback would delete text you typed. See [Optimistic UI](#optimistic-ui). |
| `optimisticArgs` | `array` | `[]` | Extra arguments appended to the optimistic action call, after the new value — the row this control belongs to. |

## Accessibility

- The input area uses `role="group"` with `aria-label` matching the label text
- Each tag chip uses `role="listitem"` inside a `role="list"` container
- Remove buttons have `aria-label="Remove {tag}"` for screen readers
- Tag creation and removal are announced via `aria-live="polite"` region
- When `maxTags` is reached, the input gets `aria-disabled="true"` and a polite announcement
- `aria-invalid="true"` and `aria-describedby` set on error or hint
- All chip remove icons are `aria-hidden="true"` — the button label provides the accessible name

## Keyboard Interaction

| Key | Action |
|-----|--------|
| `Tab` | Move focus to the tags input |
| Type characters | Build the next tag |
| `Enter` / `,` (comma) | Commit the typed text as a new tag |
| `Backspace` (in empty input) | Remove the last tag |
| `ArrowLeft` / `ArrowRight` (in empty input) | Move a virtual caret across existing tags for targeted removal |
| `Escape` | Cancel the in-progress tag |

## Pitfalls

- **Don't use tags-input for a closed taxonomy.** If the user must pick from a fixed list, `<x-wirekit::multi-select>` is correct. Tags-input is for free-form entry (skills, keywords, emails).
- **Don't allow duplicates without thought.** The component dedupes by string equality — case-sensitive. If you want case-insensitive dedup, normalize on the server before persisting.

## Design Tokens

| Token | Purpose |
| --- | --- |
| `--color-wk-bg-input` | Input area background |
| `--color-wk-bg-muted` | Tag chip background |
| `--color-wk-border-strong` | Default border color |
| `--color-wk-border-error` | Border color on error |
| `--color-wk-text` | Input and tag text color |
| `--color-wk-text-muted` | Placeholder and remove icon color |
| `--color-wk-ring` | Focus ring color |
| `--radius-wk-md` | Input container border radius |
| `--radius-wk-sm` | Tag chip border radius |
| `--transition-wk-duration` | Add/remove chip transition |
| `--opacity-wk-disabled` | Dimmed state when disabled |

## Customization

Override defaults in `config/wirekit.php`:

```php
'components' => [
    'tags-input' => ['placeholder' => 'Type and press Enter...', 'maxTags' => 10],
],
```

## Further Reading

- [MDN: `role="list"` and `role="listitem"`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/list_role)
- [MDN: `aria-live`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-live)
- [MDN: `aria-disabled`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-disabled)
- [WebAIM: Creating Accessible Forms](https://webaim.org/techniques/forms/)
