Skip to content

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
Try Enter, comma, blur, Backspace on an empty field, or a comma-separated paste.

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.

Terminal
npx 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

ExpenseTags.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

ExpenseTags.jsx
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

ExpenseTags.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

PurposeVueReactSvelte
Committed tagsv-modelvalue, onChangebind:value
Pending textv-model:draftdraft, onDraftChangebind:draft
Initial statemodel defaultsdefaultValue, defaultDraftbindable defaults
Formname, form, required, disabled, readonlysame names; readOnlysame native names
Policymax, normalize, validatesamesame
Rejection@rejectonRejectonreject
StylingclassclassNameclass

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:

NewExpense.vue
<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-describedby and aria-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:

vue
<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.

  • 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

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>

React

TagsInput.jsx
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

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>

All open source projects are released under the MIT License.