Tags input renders tags inside a control, followed by a text input. Tags are added when text is typed and the Enter or Comma key is pressed. DOM focus remains on the input element throughout the interaction.
Features
- Press Enter or Comma to add tags
- Clear all tags with a clear trigger
- Add tags by pasting
- Delete tags with Backspace
- Edit tags in place after creation
- Limit the number of tags
- Navigate tags with the keyboard
- Custom validation to accept or reject tags
Installation
npm install @zag-js/tags-input @zag-js/react
import { useMachine, normalizeProps } from "@zag-js/react"
import * as tagsInput from "@zag-js/tags-input"
import { useId } from "react"
export function TagsInput() {
const service = useMachine(tagsInput.machine, {
id: useId(),
defaultValue: ["React", "TypeScript"],
})
const api = tagsInput.connect(service, normalizeProps)
return (
<div {...api.getRootProps()}>
<label {...api.getLabelProps()}>Technologies</label>
<div {...api.getControlProps()}>
{api.value.map((value, index) => (
<div key={`${value}-${index}`} {...api.getItemProps({ index, value })}>
<div {...api.getItemPreviewProps({ index, value })}>
<span {...api.getItemTextProps({ index, value })}>{value}</span>
<button {...api.getItemDeleteTriggerProps({ index, value })}>
✕
</button>
</div>
<input {...api.getItemInputProps({ index, value })} />
</div>
))}
<input {...api.getInputProps()} placeholder="Add tag…" />
<button {...api.getClearTriggerProps()}>Clear all</button>
</div>
<input {...api.getHiddenInputProps()} />
</div>
)
}
<script setup>
import { useMachine, normalizeProps } from "@zag-js/vue"
import * as tagsInput from "@zag-js/tags-input"
import { useId, computed } from "vue"
const service = useMachine(tagsInput.machine, {
id: useId(),
defaultValue: ["React", "TypeScript"],
})
const api = computed(() => tagsInput.connect(service, normalizeProps))
</script>
<template>
<div v-bind="api.getRootProps()">
<label v-bind="api.getLabelProps()">Technologies</label>
<div v-bind="api.getControlProps()">
<div
v-for="(value, index) in api.value"
:key="`${value}-${index}`"
v-bind="api.getItemProps({ index, value })"
>
<div v-bind="api.getItemPreviewProps({ index, value })">
<span v-bind="api.getItemTextProps({ index, value })">{{ value }}</span>
<button v-bind="api.getItemDeleteTriggerProps({ index, value })">✕</button>
</div>
<input v-bind="api.getItemInputProps({ index, value })" />
</div>
<input v-bind="api.getInputProps()" placeholder="Add tag…" />
<button v-bind="api.getClearTriggerProps()">Clear all</button>
</div>
</div>
</template>
Navigating and editing tags
When the input is empty or the caret is at the start, tags can be selected with the ArrowLeft/ArrowRight keys. When a tag has visual focus:
- Press
Enter or double-click to edit. Press Enter again to commit.
- Press
Delete or Backspace to delete the tag.
Controlled tags
Use value and onValueChange to manage tags externally.
const [value, setValue] = useState(["React"])
const service = useMachine(tagsInput.machine, {
id: useId(),
value,
onValueChange(details) {
setValue(details.value)
},
})
Limiting the number of tags
Set max to limit how many tags can be added.
const service = useMachine(tagsInput.machine, {
id: useId(),
max: 10,
})
To let the count exceed the limit (and handle it yourself), add allowOverflow: true.
const service = useMachine(tagsInput.machine, {
id: useId(),
max: 5,
allowOverflow: true,
})
Custom validation
Provide a validate function to accept or reject a tag before it is added.
const service = useMachine(tagsInput.machine, {
id: useId(),
validate(details) {
// Only allow lowercase alphabetic tags
return /^[a-z]+$/.test(details.inputValue)
},
})
Allow duplicates
By default, duplicate tags are prevented. Set allowDuplicates: true for use cases like sentence builders.
const service = useMachine(tagsInput.machine, {
id: useId(),
allowDuplicates: true,
})
Blur behavior
Configure what happens when the input loses focus.
const service = useMachine(tagsInput.machine, {
id: useId(),
blurBehavior: "add", // "add" | "clear"
})
Paste behavior
Set addOnPaste to create tags from pasted text. Tags are split by the delimiter value.
const service = useMachine(tagsInput.machine, {
id: useId(),
addOnPaste: true,
delimiter: ",",
})
Disable tag editing
Prevent double-click or Enter from entering tag edit mode.
const service = useMachine(tagsInput.machine, {
id: useId(),
editable: false,
})
Usage within forms
Set name and render the hidden input for form submission support.
const service = useMachine(tagsInput.machine, {
id: useId(),
name: "tags",
})
// In JSX
<input {...api.getHiddenInputProps()} />
API reference
The initial tag values (uncontrolled).
The controlled tag values. Use with onValueChange.
The controlled value of the text input. Use with onInputValueChange.
The maximum number of tags allowed. Defaults to Infinity.
Whether to allow adding tags past the max limit.
Whether to allow duplicate tag values. Defaults to false.
Whether tags can be edited after creation. Defaults to true.
validate
(details: { inputValue: string, value: string[] }) => boolean
A function to validate a tag before it is added. Return false to reject.
The action to take on the input value when the component loses focus.
Whether to add tags from pasted text.
The delimiter used to split pasted values into individual tags. Defaults to ",".
Whether the tags input is disabled.
Whether the tags input is read-only.
Whether the tags input is in an invalid state.
The name attribute for form submission via hidden input.
onValueChange
(details: { value: string[] }) => void
Callback fired when the tag list changes.
onInputValueChange
(details: { inputValue: string }) => void
Callback fired when the text input value changes.
onHighlightChange
(details: { highlightedValue: string | null }) => void
Callback fired when a tag receives visual focus via keyboard navigation.
onValueInvalid
(details: { reason: string }) => void
Callback fired when a tag is rejected by validate or when the max is reached.
Styling
Each part has a data-part attribute.
Focused state
[data-part="root"][data-focus] { /* root when input is focused */ }
[data-part="label"][data-focus] { /* label when input is focused */ }
[data-part="input"]:focus { /* the text input itself */ }
Invalid state
[data-part="root"][data-invalid] { /* invalid root */ }
[data-part="label"][data-invalid] { /* invalid label */ }
[data-part="input"][data-invalid] { /* invalid input */ }
Disabled state
[data-part="root"][data-disabled] { /* disabled root */ }
[data-part="label"][data-disabled] { /* disabled label */ }
[data-part="input"][data-disabled] { /* disabled input */ }
[data-part="control"][data-disabled] { /* disabled control */ }
[data-part="item-preview"][data-disabled] { /* disabled tag */ }
Highlighted (visually focused) tag
[data-part="item-preview"][data-highlighted] { /* keyboard-focused tag */ }
Read-only state
[data-part="root"][data-readonly] { /* read-only root */ }
[data-part="control"][data-readonly] { /* read-only control */ }
[data-part="input"][data-readonly] { /* read-only input */ }
[data-part="label"][data-readonly] { /* read-only label */ }
Keyboard interactions
| Key | Description |
|---|
Enter | Adds the current input value as a tag |
, | Adds the current input value as a tag (default delimiter) |
ArrowLeft | When input is empty, moves visual focus to the previous tag |
ArrowRight | Moves visual focus to the next tag |
Backspace | Deletes the last tag when the input is empty; deletes the focused tag |
Delete | Deletes the visually focused tag |
Enter (on focused tag) | Enters edit mode for the focused tag |
Escape | Exits tag edit mode without committing changes |