Tags Input
Tags Input turns short free-form labels into one caller-owned string[]. It is for expense tags, customer labels, filters, and other places where people add several compact values rather than choose from a fixed list.
The API remains one component. Adding, removal, bulk paste, validation, form submission, announcements, and keyboard focus do not require item, list, remove, or input subcomponents.
TagsInput.vue
<script setup>
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs
} from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const props = defineProps({
name: { type: String, default: undefined },
placeholder: { type: String, default: 'Add a tag' },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
required: { type: Boolean, default: false },
max: { type: Number, default: Number.POSITIVE_INFINITY },
normalize: { type: Function, default: (value) => value.trim() },
validate: { type: Function, default: () => true }
})
const emit = defineEmits(['change', 'reject'])
const value = defineModel({ type: Array, default: () => [] })
const draft = defineModel('draft', { type: String, default: '' })
const attrs = useAttrs()
const root = ref()
const element = ref()
const removeElements = ref([])
const status = ref('')
let composing = false
let form
let initialValue = []
let initialDraft = ''
const invalid = computed(
() => attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true'
)
const forwardedAttrs = computed(() => {
const {
class: _class,
'data-slot': _dataSlot,
'data-disabled': _dataDisabled,
'data-invalid': _dataInvalid,
...rest
} = attrs
return rest
})
function announce(message) {
status.value = ''
nextTick(() => {
status.value = message
})
}
function rejection(raw, message) {
const detail = { value: raw, message }
announce(message)
emit('reject', detail)
return { accepted: false, raw, message }
}
function evaluate(raw, tags) {
let tag
try {
tag = String(props.normalize(String(raw)) ?? '').trim()
} catch {
return rejection(raw, 'That tag could not be normalized.')
}
if (!tag) return { accepted: false, empty: true, raw }
if (tags.length >= props.max) {
return rejection(raw, `You can add up to ${props.max} tags.`)
}
if (tags.includes(tag)) {
return rejection(raw, `${tag} is already added.`)
}
const result = props.validate(tag, tags)
if (result !== true) {
return rejection(
raw,
typeof result === 'string' && result
? result
: `${tag} is not a valid tag.`
)
}
return { accepted: true, tag, raw }
}
function setTags(tags) {
value.value = tags
emit('change', tags)
}
function addCandidates(candidates) {
const tags = [...value.value]
const rejected = []
const rejectionMessages = []
const added = []
for (const candidate of candidates) {
const result = evaluate(candidate, tags)
if (result.accepted) {
tags.push(result.tag)
added.push(result.tag)
} else if (!result.empty && String(candidate).trim()) {
rejected.push(String(candidate).trim())
rejectionMessages.push(result.message)
}
}
if (added.length) {
setTags(tags)
const addition =
added.length === 1 ? `${added[0]} added.` : `${added.length} tags added.`
announce(
rejectionMessages.length
? `${addition} ${rejectionMessages.at(-1)}`
: addition
)
} else if (rejectionMessages.length) {
announce(rejectionMessages.at(-1))
}
draft.value = rejected.join(', ')
return added.length > 0
}
function commitDraft() {
if (props.disabled || props.readonly) return false
if (!draft.value.trim()) {
draft.value = ''
return false
}
return addCandidates([draft.value])
}
function updateDraft(event) {
draft.value = event.currentTarget.value
}
function finishComposition(event) {
composing = false
updateDraft(event)
}
function handleInputKeydown(event) {
if (composing || event.isComposing) return
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault()
commitDraft()
return
}
if (event.key === 'Backspace' && !draft.value && value.value.length) {
event.preventDefault()
removeAt(value.value.length - 1, false)
element.value?.focus()
return
}
if (
event.key === 'ArrowLeft' &&
event.currentTarget.selectionStart === 0 &&
value.value.length
) {
event.preventDefault()
removeElements.value.at(-1)?.focus()
}
}
function handlePaste(event) {
if (props.disabled || props.readonly) return
const pasted = event.clipboardData?.getData('text') ?? ''
if (!/[,\n]/.test(pasted)) return
event.preventDefault()
addCandidates(`${draft.value}${pasted}`.split(/[,\n]+/))
}
function removeAt(index, restoreFocus = true) {
if (props.disabled || props.readonly || index < 0) return
const tags = [...value.value]
const [removed] = tags.splice(index, 1)
if (removed === undefined) return
setTags(tags)
announce(`${removed} removed.`)
if (restoreFocus) {
nextTick(() => {
const controls = root.value?.querySelectorAll('[data-part="remove"]')
;(controls?.[index] ?? controls?.[index - 1] ?? element.value)?.focus()
})
}
}
function handleRemoveKeydown(event, index) {
if (event.key === 'ArrowLeft') {
event.preventDefault()
removeElements.value[index - 1]?.focus() ?? element.value?.focus()
} else if (event.key === 'ArrowRight') {
event.preventDefault()
removeElements.value[index + 1]?.focus() ?? element.value?.focus()
} else if (event.key === 'Home') {
event.preventDefault()
removeElements.value[0]?.focus()
} else if (event.key === 'End') {
event.preventDefault()
element.value?.focus()
} else if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
removeAt(index)
}
}
function focusInput(event) {
if (event.target === root.value) element.value?.focus()
}
function handleReset() {
queueMicrotask(() => {
setTags([...initialValue])
draft.value = initialDraft
})
}
onMounted(() => {
initialValue = [...value.value]
initialDraft = draft.value
form = element.value?.form
form?.addEventListener('reset', handleReset)
})
onBeforeUnmount(() => form?.removeEventListener('reset', handleReset))
defineExpose({
element,
focus: (options) => element.value?.focus(options),
commit: commitDraft
})
</script>
<template>
<div
ref="root"
data-slot="tags-input"
:data-disabled="disabled ? '' : undefined"
:data-readonly="readonly ? '' : undefined"
:data-invalid="invalid ? '' : undefined"
:class="
twMerge(
[
'flex min-h-11 w-full flex-wrap items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-950 shadow-sm outline-none transition-colors duration-150',
'hover:border-gray-400 focus-within:border-gray-950 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-gray-950',
'data-disabled:cursor-not-allowed data-disabled:bg-gray-100 data-disabled:text-gray-500',
'data-invalid:border-red-600 data-invalid:focus-within:outline-red-600',
'dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:hover:border-gray-600 dark:focus-within:border-white dark:focus-within:outline-white dark:data-disabled:bg-gray-900 dark:data-disabled:text-gray-500 dark:data-invalid:border-red-500 dark:data-invalid:focus-within:outline-red-500',
'motion-reduce:transition-none'
],
attrs.class
)
"
@click="focusInput"
>
<ul v-if="value.length" role="list" data-part="list" class="contents">
<li
v-for="(tag, index) in value"
:key="`${tag}-${index}`"
data-part="tag"
class="inline-flex min-w-0 items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-100"
>
<span data-part="tag-label" class="min-w-0 truncate">{{ tag }}</span>
<button
v-if="!readonly"
:ref="(node) => (removeElements[index] = node)"
type="button"
data-part="remove"
:disabled="disabled"
:aria-label="`Remove ${tag}`"
class="-mr-1 inline-grid size-6 shrink-0 cursor-pointer place-items-center rounded-sm text-gray-500 outline-none hover:bg-gray-200 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus-visible:outline-white"
@click="removeAt(index)"
@keydown="handleRemoveKeydown($event, index)"
>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
class="size-3.5"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m5 5 10 10M15 5 5 15" />
</svg>
</button>
</li>
</ul>
<input
ref="element"
v-bind="forwardedAttrs"
:value="draft"
:placeholder="value.length ? undefined : placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required && value.length === 0"
:aria-invalid="attrs['aria-invalid']"
data-part="input"
class="min-h-6 min-w-28 flex-1 border-0 bg-transparent p-0 text-base text-inherit outline-none placeholder:text-gray-500 disabled:cursor-not-allowed dark:placeholder:text-gray-400"
@compositionstart="composing = true"
@compositionend="finishComposition"
@input="updateDraft"
@keydown="handleInputKeydown"
@paste="handlePaste"
@blur="commitDraft"
/>
<input
v-for="(tag, index) in value"
:key="`field-${tag}-${index}`"
type="hidden"
:name="name"
:value="tag"
:form="attrs.form"
:disabled="disabled || !name"
/>
<span class="sr-only" aria-live="polite" aria-atomic="true">{{
status
}}</span>
</div>
</template>Installation
One command detects Vue, React, or Svelte and copies the framework-native source into the application:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add tags-inputpnpm dlx klean-ui add tags-inputyarn dlx klean-ui add tags-inputbunx klean-ui add tags-input- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
There is no initializer, provider, klean-ui.json, generated class helper, or Durable UI runtime package.
Usage
Vue
<script setup>
import { ref } from 'vue'
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
const tags = ref(['billing', 'invoice'])
const draft = ref('')
</script>
<template>
<div class="grid gap-2">
<label for="expense-tags" class="text-sm font-medium">Tags</label>
<TagsInput
id="expense-tags"
v-model="tags"
v-model:draft="draft"
name="tags"
aria-describedby="expense-tags-help"
/>
<p id="expense-tags-help" class="text-sm text-gray-500">
Press Enter or comma to add. Paste a comma-separated list.
</p>
</div>
</template>
React
import { useState } from 'react'
import TagsInput from '@/components/ui/tags-input/TagsInput.jsx'
export default function ExpenseTags() {
const [tags, setTags] = useState(['billing', 'invoice'])
const [draft, setDraft] = useState('')
return (
<div className="grid gap-2">
<label htmlFor="expense-tags" className="text-sm font-medium">
Tags
</label>
<TagsInput
id="expense-tags"
value={tags}
onChange={setTags}
draft={draft}
onDraftChange={setDraft}
name="tags"
aria-describedby="expense-tags-help"
/>
<p id="expense-tags-help" className="text-sm text-gray-500">
Press Enter or comma to add. Paste a comma-separated list.
</p>
</div>
)
}
Svelte
<script>
import TagsInput from '$lib/components/ui/tags-input/TagsInput.svelte'
let tags = $state(['billing', 'invoice'])
let draft = $state('')
</script>
<div class="grid gap-2">
<label for="expense-tags" class="text-sm font-medium">Tags</label>
<TagsInput
id="expense-tags"
bind:value={tags}
bind:draft
name="tags"
aria-describedby="expense-tags-help"
/>
<p id="expense-tags-help" class="text-sm text-gray-500">
Press Enter or comma to add. Paste a comma-separated list.
</p>
</div>
The syntax changes with the framework; the committed tags, pending draft, native form, and interaction contract do not.
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Committed tags | v-model | value, onChange | bind:value |
| Pending text | v-model:draft | draft, onDraftChange | bind:draft |
| Initial state | model defaults | defaultValue, defaultDraft | bindable defaults |
| Form | name, form, required, disabled, readonly | same names; readOnly | same native names |
| Policy | max, normalize, validate | same | same |
| Rejection | @reject | onReject | onreject |
| Styling | class | className | class |
value is always an array of strings. normalize(tag) returns the stored string. validate(tag, currentTags) returns true, false, or a useful error message. Duplicate normalized tags are rejected. max limits the committed array.
Paste and blur are conventions, not configuration. A comma- or newline-separated paste adds every valid tag in one action. Blur commits useful pending text. Rejected, duplicate, or over-limit text remains in the editable draft so it can be corrected instead of disappearing.
Native form behavior
When name is present, each committed tag becomes one repeated form value. A field with name="tags" produces formData.getAll('tags'). The unfinished draft is never submitted as if it were committed.
required stays on the real text field until one tag exists. disabled removes the field from submission. readonly keeps committed tags visible and submittable while removing edit controls. A native form reset restores both the initial tags and initial draft.
For Inertia or JSON requests, submit the caller-owned array directly. There is no need to serialize it through a hidden comma string unless the server contract specifically requires that shape.
Durable form drafts
Committed tags are not the whole form state. Someone may type a useful tag, navigate away before pressing Enter, then return. Bind the pending draft when the application promises form restoration:
<script setup>
import { useForm } from '@inertiajs/vue3'
import { useFormDraft } from '@/composables/form-draft'
import TagsInput from '@/components/ui/tags-input/TagsInput.vue'
const form = useForm({
tags: [],
tagsDraft: ''
})
const draft = useFormDraft('expense:new', form)
function submit() {
form
.transform(({ tagsDraft: _draft, ...data }) => data)
.post('/expenses', {
onSuccess: () => draft.discardDraft()
})
}
</script>
<template>
<form @submit.prevent="submit">
<label for="expense-tags">Tags</label>
<TagsInput
id="expense-tags"
v-model="form.tags"
v-model:draft="form.tagsDraft"
:aria-invalid="Boolean(form.errors.tags)"
aria-describedby="expense-tags-error"
/>
<p id="expense-tags-error" class="empty:hidden text-sm text-red-700">
{{ form.errors.tags }}
</p>
<button type="submit" :disabled="form.processing">Save expense</button>
</form>
</template>
Persist the tags and draft together under the application's versioned, expiring form-draft key. Exclude the draft from the request payload and clear it after a successful submission. Sensitive data still does not belong in browser storage.
Klean UI implements the applicable Durable UI behavior inside the component: caller-owned state, correction-safe rejection, native reset, predictable focus after removal, and clean teardown. Durable UI remains the doctrine; it is not a second component package that applications must install.
Keyboard and accessibility
- Give the real text field a visible associated
<label>. - Enter and comma commit pending text. IME composition is never committed early.
- Backspace in an empty field removes the final tag and keeps focus in the input.
- Arrow Left at the start of the input reaches the final remove button.
- Arrow keys move between remove buttons; Home reaches the first and End returns to the input.
- Delete or Backspace on a focused remove button removes that tag, then focuses the next tag, previous tag, or input.
- Every remove control is a real
type="button"with the tag in its accessible name. - Additions, removals, duplicates, limits, and validation failures are announced politely.
- Help and application validation messages connect through ordinary
aria-describedbyandaria-invalid.
The component does not invent a composite ARIA widget. It keeps a real text input and real buttons because the browser already provides their semantics.
Styling
class or className merges onto the field root. Klean supplies a neutral monochrome default; caller Tailwind wins. Stable data-part hooks expose list, tag, tag-label, remove, and input without adding part-class props:
<TagsInput
v-model="tags"
class="rounded-none border-2 **:data-[part=tag]:rounded-none **:data-[part=tag]:bg-amber-100"
/>There are no variant, tone, size, tagClass, removeClass, addOnBlur, addOnPaste, or allowDuplicates props. Repeated product treatments belong in an application-owned component, and the copied source is the final escape hatch.
Related components
- Input — one arbitrary text value.
- Select — one value from a known fixed list.
- Combobox — one searchable value from suggestions.
- Badge — read-only status or compact metadata, not editable tags.
- Button — form submission and ordinary actions.
Complete framework source
Vue
<script setup>
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs
} from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const props = defineProps({
name: { type: String, default: undefined },
placeholder: { type: String, default: 'Add a tag' },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
required: { type: Boolean, default: false },
max: { type: Number, default: Number.POSITIVE_INFINITY },
normalize: { type: Function, default: (value) => value.trim() },
validate: { type: Function, default: () => true }
})
const emit = defineEmits(['change', 'reject'])
const value = defineModel({ type: Array, default: () => [] })
const draft = defineModel('draft', { type: String, default: '' })
const attrs = useAttrs()
const root = ref()
const element = ref()
const removeElements = ref([])
const status = ref('')
let composing = false
let form
let initialValue = []
let initialDraft = ''
const invalid = computed(
() => attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true'
)
const forwardedAttrs = computed(() => {
const {
class: _class,
'data-slot': _dataSlot,
'data-disabled': _dataDisabled,
'data-invalid': _dataInvalid,
...rest
} = attrs
return rest
})
function announce(message) {
status.value = ''
nextTick(() => {
status.value = message
})
}
function rejection(raw, message) {
const detail = { value: raw, message }
announce(message)
emit('reject', detail)
return { accepted: false, raw, message }
}
function evaluate(raw, tags) {
let tag
try {
tag = String(props.normalize(String(raw)) ?? '').trim()
} catch {
return rejection(raw, 'That tag could not be normalized.')
}
if (!tag) return { accepted: false, empty: true, raw }
if (tags.length >= props.max) {
return rejection(raw, `You can add up to ${props.max} tags.`)
}
if (tags.includes(tag)) {
return rejection(raw, `${tag} is already added.`)
}
const result = props.validate(tag, tags)
if (result !== true) {
return rejection(
raw,
typeof result === 'string' && result
? result
: `${tag} is not a valid tag.`
)
}
return { accepted: true, tag, raw }
}
function setTags(tags) {
value.value = tags
emit('change', tags)
}
function addCandidates(candidates) {
const tags = [...value.value]
const rejected = []
const rejectionMessages = []
const added = []
for (const candidate of candidates) {
const result = evaluate(candidate, tags)
if (result.accepted) {
tags.push(result.tag)
added.push(result.tag)
} else if (!result.empty && String(candidate).trim()) {
rejected.push(String(candidate).trim())
rejectionMessages.push(result.message)
}
}
if (added.length) {
setTags(tags)
const addition =
added.length === 1 ? `${added[0]} added.` : `${added.length} tags added.`
announce(
rejectionMessages.length
? `${addition} ${rejectionMessages.at(-1)}`
: addition
)
} else if (rejectionMessages.length) {
announce(rejectionMessages.at(-1))
}
draft.value = rejected.join(', ')
return added.length > 0
}
function commitDraft() {
if (props.disabled || props.readonly) return false
if (!draft.value.trim()) {
draft.value = ''
return false
}
return addCandidates([draft.value])
}
function updateDraft(event) {
draft.value = event.currentTarget.value
}
function finishComposition(event) {
composing = false
updateDraft(event)
}
function handleInputKeydown(event) {
if (composing || event.isComposing) return
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault()
commitDraft()
return
}
if (event.key === 'Backspace' && !draft.value && value.value.length) {
event.preventDefault()
removeAt(value.value.length - 1, false)
element.value?.focus()
return
}
if (
event.key === 'ArrowLeft' &&
event.currentTarget.selectionStart === 0 &&
value.value.length
) {
event.preventDefault()
removeElements.value.at(-1)?.focus()
}
}
function handlePaste(event) {
if (props.disabled || props.readonly) return
const pasted = event.clipboardData?.getData('text') ?? ''
if (!/[,\n]/.test(pasted)) return
event.preventDefault()
addCandidates(`${draft.value}${pasted}`.split(/[,\n]+/))
}
function removeAt(index, restoreFocus = true) {
if (props.disabled || props.readonly || index < 0) return
const tags = [...value.value]
const [removed] = tags.splice(index, 1)
if (removed === undefined) return
setTags(tags)
announce(`${removed} removed.`)
if (restoreFocus) {
nextTick(() => {
const controls = root.value?.querySelectorAll('[data-part="remove"]')
;(controls?.[index] ?? controls?.[index - 1] ?? element.value)?.focus()
})
}
}
function handleRemoveKeydown(event, index) {
if (event.key === 'ArrowLeft') {
event.preventDefault()
removeElements.value[index - 1]?.focus() ?? element.value?.focus()
} else if (event.key === 'ArrowRight') {
event.preventDefault()
removeElements.value[index + 1]?.focus() ?? element.value?.focus()
} else if (event.key === 'Home') {
event.preventDefault()
removeElements.value[0]?.focus()
} else if (event.key === 'End') {
event.preventDefault()
element.value?.focus()
} else if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
removeAt(index)
}
}
function focusInput(event) {
if (event.target === root.value) element.value?.focus()
}
function handleReset() {
queueMicrotask(() => {
setTags([...initialValue])
draft.value = initialDraft
})
}
onMounted(() => {
initialValue = [...value.value]
initialDraft = draft.value
form = element.value?.form
form?.addEventListener('reset', handleReset)
})
onBeforeUnmount(() => form?.removeEventListener('reset', handleReset))
defineExpose({
element,
focus: (options) => element.value?.focus(options),
commit: commitDraft
})
</script>
<template>
<div
ref="root"
data-slot="tags-input"
:data-disabled="disabled ? '' : undefined"
:data-readonly="readonly ? '' : undefined"
:data-invalid="invalid ? '' : undefined"
:class="
twMerge(
[
'flex min-h-11 w-full flex-wrap items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-950 shadow-sm outline-none transition-colors duration-150',
'hover:border-gray-400 focus-within:border-gray-950 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-gray-950',
'data-disabled:cursor-not-allowed data-disabled:bg-gray-100 data-disabled:text-gray-500',
'data-invalid:border-red-600 data-invalid:focus-within:outline-red-600',
'dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:hover:border-gray-600 dark:focus-within:border-white dark:focus-within:outline-white dark:data-disabled:bg-gray-900 dark:data-disabled:text-gray-500 dark:data-invalid:border-red-500 dark:data-invalid:focus-within:outline-red-500',
'motion-reduce:transition-none'
],
attrs.class
)
"
@click="focusInput"
>
<ul v-if="value.length" role="list" data-part="list" class="contents">
<li
v-for="(tag, index) in value"
:key="`${tag}-${index}`"
data-part="tag"
class="inline-flex min-w-0 items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-100"
>
<span data-part="tag-label" class="min-w-0 truncate">{{ tag }}</span>
<button
v-if="!readonly"
:ref="(node) => (removeElements[index] = node)"
type="button"
data-part="remove"
:disabled="disabled"
:aria-label="`Remove ${tag}`"
class="-mr-1 inline-grid size-6 shrink-0 cursor-pointer place-items-center rounded-sm text-gray-500 outline-none hover:bg-gray-200 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus-visible:outline-white"
@click="removeAt(index)"
@keydown="handleRemoveKeydown($event, index)"
>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
class="size-3.5"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m5 5 10 10M15 5 5 15" />
</svg>
</button>
</li>
</ul>
<input
ref="element"
v-bind="forwardedAttrs"
:value="draft"
:placeholder="value.length ? undefined : placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required && value.length === 0"
:aria-invalid="attrs['aria-invalid']"
data-part="input"
class="min-h-6 min-w-28 flex-1 border-0 bg-transparent p-0 text-base text-inherit outline-none placeholder:text-gray-500 disabled:cursor-not-allowed dark:placeholder:text-gray-400"
@compositionstart="composing = true"
@compositionend="finishComposition"
@input="updateDraft"
@keydown="handleInputKeydown"
@paste="handlePaste"
@blur="commitDraft"
/>
<input
v-for="(tag, index) in value"
:key="`field-${tag}-${index}`"
type="hidden"
:name="name"
:value="tag"
:form="attrs.form"
:disabled="disabled || !name"
/>
<span class="sr-only" aria-live="polite" aria-atomic="true">{{
status
}}</span>
</div>
</template>
React
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'
import { twMerge } from 'tailwind-merge'
const ROOT_CLASSES = [
'flex min-h-11 w-full flex-wrap items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-950 shadow-sm outline-none transition-colors duration-150',
'hover:border-gray-400 focus-within:border-gray-950 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-gray-950',
'data-disabled:cursor-not-allowed data-disabled:bg-gray-100 data-disabled:text-gray-500',
'data-invalid:border-red-600 data-invalid:focus-within:outline-red-600',
'dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:hover:border-gray-600 dark:focus-within:border-white dark:focus-within:outline-white dark:data-disabled:bg-gray-900 dark:data-disabled:text-gray-500 dark:data-invalid:border-red-500 dark:data-invalid:focus-within:outline-red-500',
'motion-reduce:transition-none'
]
const defaultNormalize = (value) => value.trim()
const defaultValidate = () => true
function assignRef(ref, value) {
if (typeof ref === 'function') ref(value)
else if (ref) ref.current = value
}
const TagsInput = forwardRef(function TagsInput(
{
value,
defaultValue = [],
onChange,
draft,
defaultDraft = '',
onDraftChange,
onReject,
name,
form,
placeholder = 'Add a tag',
disabled = false,
readOnly = false,
required = false,
max = Number.POSITIVE_INFINITY,
normalize = defaultNormalize,
validate = defaultValidate,
className,
onBlur,
onKeyDown,
onPaste,
onCompositionStart,
onCompositionEnd,
'aria-invalid': ariaInvalid,
'data-slot': _dataSlot,
'data-disabled': _dataDisabled,
'data-invalid': _dataInvalid,
...inputProps
},
forwardedRef
) {
const rootRef = useRef(null)
const inputRef = useRef(null)
const removeRefs = useRef([])
const composing = useRef(false)
const valueControlled = value !== undefined
const draftControlled = draft !== undefined
const [localValue, setLocalValue] = useState(defaultValue)
const [localDraft, setLocalDraft] = useState(defaultDraft)
const [status, setStatus] = useState('')
const tags = valueControlled ? value : localValue
const pending = draftControlled ? draft : localDraft
const initialValueRef = useRef([...tags])
const initialDraftRef = useRef(pending)
const invalid = ariaInvalid === true || ariaInvalid === 'true'
const setInput = useCallback(
(node) => {
inputRef.current = node
assignRef(forwardedRef, node)
},
[forwardedRef]
)
function announce(message) {
setStatus('')
queueMicrotask(() => setStatus(message))
}
function setTags(next) {
if (!valueControlled) setLocalValue(next)
onChange?.(next)
}
function setDraft(next) {
if (!draftControlled) setLocalDraft(next)
onDraftChange?.(next)
}
function rejection(raw, message) {
announce(message)
onReject?.({ value: raw, message })
return { accepted: false, raw, message }
}
function evaluate(raw, currentTags) {
let tag
try {
tag = String(normalize(String(raw)) ?? '').trim()
} catch {
return rejection(raw, 'That tag could not be normalized.')
}
if (!tag) return { accepted: false, empty: true, raw }
if (currentTags.length >= max) {
return rejection(raw, `You can add up to ${max} tags.`)
}
if (currentTags.includes(tag)) {
return rejection(raw, `${tag} is already added.`)
}
const result = validate(tag, currentTags)
if (result !== true) {
return rejection(
raw,
typeof result === 'string' && result
? result
: `${tag} is not a valid tag.`
)
}
return { accepted: true, tag, raw }
}
function addCandidates(candidates) {
const next = [...tags]
const rejected = []
const rejectionMessages = []
const added = []
for (const candidate of candidates) {
const result = evaluate(candidate, next)
if (result.accepted) {
next.push(result.tag)
added.push(result.tag)
} else if (!result.empty && String(candidate).trim()) {
rejected.push(String(candidate).trim())
rejectionMessages.push(result.message)
}
}
if (added.length) {
setTags(next)
const addition =
added.length === 1
? `${added[0]} added.`
: `${added.length} tags added.`
announce(
rejectionMessages.length
? `${addition} ${rejectionMessages.at(-1)}`
: addition
)
} else if (rejectionMessages.length) {
announce(rejectionMessages.at(-1))
}
setDraft(rejected.join(', '))
return added.length > 0
}
function commitDraft() {
if (disabled || readOnly) return false
if (!pending.trim()) {
setDraft('')
return false
}
return addCandidates([pending])
}
function removeAt(index, restoreFocus = true) {
if (disabled || readOnly || index < 0) return
const next = [...tags]
const [removed] = next.splice(index, 1)
if (removed === undefined) return
setTags(next)
announce(`${removed} removed.`)
if (restoreFocus) {
requestAnimationFrame(() => {
const controls = rootRef.current?.querySelectorAll(
'[data-part="remove"]'
)
const target =
controls?.[index] ?? controls?.[index - 1] ?? inputRef.current
target?.focus()
})
}
}
function handleInputKeyDown(event) {
onKeyDown?.(event)
if (event.defaultPrevented || composing.current || event.isComposing) return
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault()
commitDraft()
} else if (event.key === 'Backspace' && !pending && tags.length) {
event.preventDefault()
removeAt(tags.length - 1, false)
inputRef.current?.focus()
} else if (
event.key === 'ArrowLeft' &&
event.currentTarget.selectionStart === 0 &&
tags.length
) {
event.preventDefault()
removeRefs.current.at(-1)?.focus()
}
}
function handleRemoveKeyDown(event, index) {
if (event.key === 'ArrowLeft') {
event.preventDefault()
;(removeRefs.current[index - 1] ?? inputRef.current)?.focus()
} else if (event.key === 'ArrowRight') {
event.preventDefault()
;(removeRefs.current[index + 1] ?? inputRef.current)?.focus()
} else if (event.key === 'Home') {
event.preventDefault()
removeRefs.current[0]?.focus()
} else if (event.key === 'End') {
event.preventDefault()
inputRef.current?.focus()
} else if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault()
removeAt(index)
}
}
function handlePaste(event) {
onPaste?.(event)
if (event.defaultPrevented || disabled || readOnly) return
const pasted = event.clipboardData?.getData('text') ?? ''
if (!/[,\n]/.test(pasted)) return
event.preventDefault()
addCandidates(`${pending}${pasted}`.split(/[,\n]+/))
}
function handleBlur(event) {
onBlur?.(event)
if (!event.defaultPrevented) commitDraft()
}
useEffect(() => {
const owner = inputRef.current?.form
if (!owner) return
function handleReset() {
queueMicrotask(() => {
const next = [...initialValueRef.current]
const nextDraft = initialDraftRef.current
if (!valueControlled) setLocalValue(next)
if (!draftControlled) setLocalDraft(nextDraft)
onChange?.(next)
onDraftChange?.(nextDraft)
})
}
owner.addEventListener('reset', handleReset)
return () => owner.removeEventListener('reset', handleReset)
}, [draftControlled, onChange, onDraftChange, valueControlled])
return (
<div
ref={rootRef}
data-slot="tags-input"
data-disabled={disabled ? '' : undefined}
data-readonly={readOnly ? '' : undefined}
data-invalid={invalid ? '' : undefined}
className={twMerge(ROOT_CLASSES, className)}
onClick={(event) => {
if (event.target === rootRef.current) inputRef.current?.focus()
}}
>
{tags.length > 0 && (
<ul role="list" data-part="list" className="contents">
{tags.map((tag, index) => (
<li
key={`${tag}-${index}`}
data-part="tag"
className="inline-flex min-w-0 items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-100"
>
<span data-part="tag-label" className="min-w-0 truncate">
{tag}
</span>
{!readOnly && (
<button
ref={(node) => {
removeRefs.current[index] = node
}}
type="button"
data-part="remove"
disabled={disabled}
aria-label={`Remove ${tag}`}
className="-mr-1 inline-grid size-6 shrink-0 cursor-pointer place-items-center rounded-sm text-gray-500 outline-none hover:bg-gray-200 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus-visible:outline-white"
onClick={() => removeAt(index)}
onKeyDown={(event) => handleRemoveKeyDown(event, index)}
>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
className="size-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="m5 5 10 10M15 5 5 15" />
</svg>
</button>
)}
</li>
))}
</ul>
)}
<input
{...inputProps}
ref={setInput}
value={pending}
placeholder={tags.length ? undefined : placeholder}
disabled={disabled}
readOnly={readOnly}
form={form}
required={required && tags.length === 0}
aria-invalid={ariaInvalid}
data-part="input"
className="min-h-6 min-w-28 flex-1 border-0 bg-transparent p-0 text-base text-inherit outline-none placeholder:text-gray-500 disabled:cursor-not-allowed dark:placeholder:text-gray-400"
onChange={(event) => setDraft(event.currentTarget.value)}
onCompositionStart={(event) => {
composing.current = true
onCompositionStart?.(event)
}}
onCompositionEnd={(event) => {
composing.current = false
setDraft(event.currentTarget.value)
onCompositionEnd?.(event)
}}
onKeyDown={handleInputKeyDown}
onPaste={handlePaste}
onBlur={handleBlur}
/>
{tags.map((tag, index) => (
<input
key={`field-${tag}-${index}`}
type="hidden"
name={name}
value={tag}
form={form}
disabled={disabled || !name}
/>
))}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{status}
</span>
</div>
)
})
export default TagsInput
Svelte
<script>
import { tick } from "svelte";
import { twMerge } from "tailwind-merge";
const ROOT_CLASSES = [
"flex min-h-11 w-full flex-wrap items-center gap-2 rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-950 shadow-sm outline-none transition-colors duration-150",
"hover:border-gray-400 focus-within:border-gray-950 focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-gray-950",
"data-disabled:cursor-not-allowed data-disabled:bg-gray-100 data-disabled:text-gray-500",
"data-invalid:border-red-600 data-invalid:focus-within:outline-red-600",
"dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:hover:border-gray-600 dark:focus-within:border-white dark:focus-within:outline-white dark:data-disabled:bg-gray-900 dark:data-disabled:text-gray-500 dark:data-invalid:border-red-500 dark:data-invalid:focus-within:outline-red-500",
"motion-reduce:transition-none",
];
let {
value = $bindable([]),
draft = $bindable(""),
onchange,
ondraftchange,
onreject,
name,
form,
placeholder = "Add a tag",
disabled = false,
readonly = false,
required = false,
max = Number.POSITIVE_INFINITY,
normalize = (candidate) => candidate.trim(),
validate = () => true,
class: className,
onblur,
onkeydown,
onpaste,
oncompositionstart,
oncompositionend,
"aria-invalid": ariaInvalid,
"data-slot": _dataSlot,
"data-disabled": _dataDisabled,
"data-invalid": _dataInvalid,
...inputProps
} = $props();
let element = $state();
let removeElements = $state([]);
let status = $state("");
let composing = false;
const initialValue = [...value];
const initialDraft = draft;
let invalid = $derived(ariaInvalid === true || ariaInvalid === "true");
function announce(message) {
status = "";
queueMicrotask(() => {
status = message;
});
}
function setTags(next) {
value = next;
onchange?.(next);
}
function setDraft(next) {
draft = next;
ondraftchange?.(next);
}
function rejection(raw, message) {
announce(message);
onreject?.({ value: raw, message });
return { accepted: false, raw, message };
}
function evaluate(raw, tags) {
let tag;
try {
tag = String(normalize(String(raw)) ?? "").trim();
} catch {
return rejection(raw, "That tag could not be normalized.");
}
if (!tag) return { accepted: false, empty: true, raw };
if (tags.length >= max) {
return rejection(raw, `You can add up to ${max} tags.`);
}
if (tags.includes(tag)) {
return rejection(raw, `${tag} is already added.`);
}
const result = validate(tag, tags);
if (result !== true) {
return rejection(
raw,
typeof result === "string" && result
? result
: `${tag} is not a valid tag.`,
);
}
return { accepted: true, tag, raw };
}
function addCandidates(candidates) {
const next = [...value];
const rejected = [];
const rejectionMessages = [];
const added = [];
for (const candidate of candidates) {
const result = evaluate(candidate, next);
if (result.accepted) {
next.push(result.tag);
added.push(result.tag);
} else if (!result.empty && String(candidate).trim()) {
rejected.push(String(candidate).trim());
rejectionMessages.push(result.message);
}
}
if (added.length) {
setTags(next);
const addition =
added.length === 1
? `${added[0]} added.`
: `${added.length} tags added.`;
announce(
rejectionMessages.length
? `${addition} ${rejectionMessages.at(-1)}`
: addition,
);
} else if (rejectionMessages.length) {
announce(rejectionMessages.at(-1));
}
setDraft(rejected.join(", "));
return added.length > 0;
}
export function commit() {
if (disabled || readonly) return false;
if (!draft.trim()) {
setDraft("");
return false;
}
return addCandidates([draft]);
}
async function removeAt(index, restoreFocus = true) {
if (disabled || readonly || index < 0) return;
const next = [...value];
const [removed] = next.splice(index, 1);
if (removed === undefined) return;
setTags(next);
announce(`${removed} removed.`);
if (restoreFocus) {
await tick();
const controls = element
?.closest('[data-slot="tags-input"]')
?.querySelectorAll('[data-part="remove"]');
(controls?.[index] ?? controls?.[index - 1] ?? element)?.focus();
}
}
function handleInputKeydown(event) {
onkeydown?.(event);
if (event.defaultPrevented || composing || event.isComposing) return;
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
commit();
} else if (event.key === "Backspace" && !draft && value.length) {
event.preventDefault();
removeAt(value.length - 1, false);
element?.focus();
} else if (
event.key === "ArrowLeft" &&
event.currentTarget.selectionStart === 0 &&
value.length
) {
event.preventDefault();
removeElements.at(-1)?.focus();
}
}
function handleRemoveKeydown(event, index) {
if (event.key === "ArrowLeft") {
event.preventDefault();
(removeElements[index - 1] ?? element)?.focus();
} else if (event.key === "ArrowRight") {
event.preventDefault();
(removeElements[index + 1] ?? element)?.focus();
} else if (event.key === "Home") {
event.preventDefault();
removeElements[0]?.focus();
} else if (event.key === "End") {
event.preventDefault();
element?.focus();
} else if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
removeAt(index);
}
}
function handlePaste(event) {
onpaste?.(event);
if (event.defaultPrevented || disabled || readonly) return;
const pasted = event.clipboardData?.getData("text") ?? "";
if (!/[,\n]/.test(pasted)) return;
event.preventDefault();
addCandidates(`${draft}${pasted}`.split(/[,\n]+/));
}
function handleBlur(event) {
onblur?.(event);
if (!event.defaultPrevented) commit();
}
$effect(() => {
const form = element?.form;
if (!form) return;
function handleReset() {
queueMicrotask(() => {
setTags([...initialValue]);
setDraft(initialDraft);
});
}
form.addEventListener("reset", handleReset);
return () => form.removeEventListener("reset", handleReset);
});
export function getElement() {
return element;
}
export function focus(options) {
element?.focus(options);
}
</script>
<div
data-slot="tags-input"
data-disabled={disabled ? "" : undefined}
data-readonly={readonly ? "" : undefined}
data-invalid={invalid ? "" : undefined}
class={twMerge(ROOT_CLASSES, className)}
>
{#if value.length}
<ul role="list" data-part="list" class="contents">
{#each value as tag, index (`${tag}-${index}`)}
<li
data-part="tag"
class="inline-flex min-w-0 items-center gap-1 rounded-md bg-gray-100 px-2 py-1 text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-100"
>
<span data-part="tag-label" class="min-w-0 truncate">{tag}</span>
{#if !readonly}
<button
bind:this={removeElements[index]}
type="button"
data-part="remove"
{disabled}
aria-label={`Remove ${tag}`}
class="-mr-1 inline-grid size-6 shrink-0 cursor-pointer place-items-center rounded-sm text-gray-500 outline-none hover:bg-gray-200 hover:text-gray-950 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white dark:focus-visible:outline-white"
onclick={() => removeAt(index)}
onkeydown={(event) => handleRemoveKeydown(event, index)}
>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
class="size-3.5"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="m5 5 10 10M15 5 5 15" />
</svg>
</button>
{/if}
</li>
{/each}
</ul>
{/if}
<input
{...inputProps}
bind:this={element}
value={draft}
placeholder={value.length ? undefined : placeholder}
{disabled}
{readonly}
{form}
required={required && value.length === 0}
aria-invalid={ariaInvalid}
data-part="input"
class="min-h-6 min-w-28 flex-1 border-0 bg-transparent p-0 text-base text-inherit outline-none placeholder:text-gray-500 disabled:cursor-not-allowed dark:placeholder:text-gray-400"
oninput={(event) => setDraft(event.currentTarget.value)}
oncompositionstart={(event) => {
composing = true;
oncompositionstart?.(event);
}}
oncompositionend={(event) => {
composing = false;
setDraft(event.currentTarget.value);
oncompositionend?.(event);
}}
onkeydown={handleInputKeydown}
onpaste={handlePaste}
onblur={handleBlur}
/>
{#each value as tag, index (`field-${tag}-${index}`)}
<input
type="hidden"
{name}
{form}
value={tag}
disabled={disabled || !name}
/>
{/each}
<span class="sr-only" aria-live="polite" aria-atomic="true">{status}</span>
</div>