← Blog
17 Sept 2026Vuetags inputaccessibilityformsAI apps

Vue Tags Input: Build an Accessible Multi-Value Field for AI Apps

Build an accessible Vue tags input with validation, paste handling, keyboard navigation, suggestions, and reliable form submission for AI apps.

Vue Tags Input: Build an Accessible Multi-Value Field for AI Apps

An accessible tags input turns one text field into a dependable multi-value control. Users can add prompt labels, recipients, filters, or document topics without losing keyboard access, validation feedback, or a clean payload on submit.

In this guide, we will build a Vue tags input that accepts typed and pasted values, rejects duplicates, supports suggestions, removes tags predictably, and submits a normalized array to an AI application.

Table of contents

Before you start

You need Vue 3, TypeScript, and a form endpoint or submit handler. Decide these product rules before writing the component:

  • Are values free-form, selected from an approved vocabulary, or both?
  • What makes a tag invalid: length, characters, duplicates, permissions, or a maximum count?
  • Should a comma or Enter create a tag? We recommend keeping Tab for normal keyboard navigation.
  • Does your backend expect repeated form values, a string array, or tag objects with IDs?

For an approved list with exactly one selected value, use a Select Input instead. A tags input is the better fit when users must build a collection, such as summarize, legal-review, and customer-email, in one field.

1. Choose the interaction model before building

A tags input is not automatically a combobox. Start with the smallest interaction that matches the data:

  • Free-form tags input: users can add new values. Use this for prompt labels, document topics, or recipient email addresses.
  • Tags input with suggestions: users may enter new values but can choose known options. This works well for AI workflow labels and saved filters.
  • Closed-vocabulary multi-select: every value must come from an approved set. Prefer a dedicated multi-select or a controlled listbox.

If suggestions appear, the text entry behaves like an editable combobox. The W3C combobox pattern recommends keeping focus on the editable input, exposing popup state, and allowing the browser to retain normal text-editing behavior. Do not intercept ordinary cursor movement, selection, copy, or paste shortcuts.

Expected result: the component has one clearly defined rule for whether an arbitrary value is valid.

Troubleshooting: if your API only accepts known IDs, do not accept a typed label and try to resolve it later. Require a suggestion selection, then store the approved ID.

2. Model tags as data, then centralize validation

Do not store display text alone when a tag may later need metadata. A small object lets us preserve a stable key, a human-readable label, and a normalized value for comparison and submission.

type Tag = {
  id: string
  label: string
  value: string
}

const normalizeTag = (value: string) =>
  value.trim().replace(/\s+/g, ' ').toLocaleLowerCase()

For AI prompt tags, keep the label users entered but compare the normalized value. That means Research, research, and RESEARCH are one logical tag. Validate in one function so typed input, pasted values, and selected suggestions follow the same rules.

Pasted prompt tags becoming individual validated tokens in a form field

Here is a complete Vue 3 single-file component. It uses defineModel, available in Vue 3.4 and later. If your project uses an earlier Vue version, replace it with a modelValue prop and update:modelValue emit.

<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'

type Tag = { id: string; label: string; value: string }

const props = withDefaults(defineProps<{
  id: string
  name?: string
  label: string
  description?: string
  error?: string
  suggestions?: string[]
  max?: number
  required?: boolean
}>(), {
  name: 'tags',
  description: '',
  error: '',
  suggestions: () => [],
  max: 8,
  required: false,
})

const tags = defineModel<Tag[]>({ default: () => [] })
const draft = ref('')
const localError = ref('')
const activeSuggestion = ref(-1)
const tagButtons = ref<HTMLButtonElement[]>([])

const normalize = (value: string) =>
  value.trim().replace(/\s+/g, ' ').toLocaleLowerCase()

const visibleSuggestions = computed(() => {
  const query = normalize(draft.value)
  if (!query) return []

  return props.suggestions
    .filter((item) => normalize(item).includes(query))
    .filter((item) => !tags.value.some((tag) => tag.value === normalize(item)))
    .slice(0, 6)
})

const errorMessage = computed(() => props.error || localError.value)
const hintId = `${props.id}-hint`
const errorId = `${props.id}-error`
const listboxId = `${props.id}-suggestions`

function setTagButton(el: Element | null, index: number) {
  if (el instanceof HTMLButtonElement) tagButtons.value[index] = el
}

function focusTag(index: number) {
  nextTick(() => tagButtons.value[index]?.focus())
}

function makeTag(raw: string): Tag | null {
  const label = raw.trim().replace(/\s+/g, ' ')
  const value = normalize(label)
  localError.value = ''

  if (!value) return null
  if (tags.value.length >= props.max) {
    localError.value = `Add up to ${props.max} tags.`
    return null
  }
  if (label.length > 32) {
    localError.value = 'Keep each tag to 32 characters or fewer.'
    return null
  }
  if (!/^[\p{L}\p{N}][\p{L}\p{N} .:/_-]*$/u.test(label)) {
    localError.value = 'Use letters, numbers, spaces, and basic punctuation only.'
    return null
  }
  if (tags.value.some((tag) => tag.value === value)) {
    localError.value = 'That tag has already been added.'
    return null
  }

  return {
    id: `${value}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
    label,
    value,
  }
}

function addTag(raw: string) {
  const tag = makeTag(raw)
  if (!tag) return false
  tags.value = [...tags.value, tag]
  return true
}

function commitDraft() {
  if (addTag(draft.value)) draft.value = ''
  activeSuggestion.value = -1
}

function selectSuggestion(label: string) {
  if (addTag(label)) draft.value = ''
  activeSuggestion.value = -1
}

function removeTag(index: number) {
  tags.value = tags.value.filter((_, tagIndex) => tagIndex !== index)
}

function removeAndFocus(index: number) {
  removeTag(index)
  const nextIndex = Math.min(index, tags.value.length - 1)
  if (nextIndex >= 0) focusTag(nextIndex)
  else nextTick(() => document.getElementById(props.id)?.focus())
}

function onPaste(event: ClipboardEvent) {
  event.preventDefault()
  const pasted = event.clipboardData?.getData('text') ?? ''
  const values = `${draft.value} ${pasted}`.split(/[ ,;\t\n\r]+/)
  draft.value = ''
  values.forEach(addTag)
}

function onInputKeydown(event: KeyboardEvent) {
  if (event.isComposing) return

  if (event.key === 'ArrowDown' && visibleSuggestions.value.length) {
    event.preventDefault()
    activeSuggestion.value = (activeSuggestion.value + 1) % visibleSuggestions.value.length
    return
  }

  if (event.key === 'ArrowUp' && visibleSuggestions.value.length) {
    event.preventDefault()
    activeSuggestion.value = activeSuggestion.value <= 0
      ? visibleSuggestions.value.length - 1
      : activeSuggestion.value - 1
    return
  }

  if (event.key === 'Escape') {
    activeSuggestion.value = -1
    return
  }

  if (event.key === 'Enter' || event.key === ',' || event.key === ';') {
    event.preventDefault()
    const suggestion = visibleSuggestions.value[activeSuggestion.value]
    if (suggestion) selectSuggestion(suggestion)
    else commitDraft()
    return
  }

  if (event.key === 'Backspace' && !draft.value && tags.value.length) {
    event.preventDefault()
    const lastIndex = tags.value.length - 1
    removeTag(lastIndex)
    if (lastIndex > 0) focusTag(lastIndex - 1)
  }
}
</script>

<template>
  <div class="tags-field">
    <label :for="id">{{ label }}<span v-if="required"> *</span></label>
    <p v-if="description" :id="hintId">{{ description }}</p>

    <div class="tags-control">
      <button
        v-for="(tag, index) in tags"
        :key="tag.id"
        :ref="(el) => setTagButton(el, index)"
        type="button"
        class="tag"
        :aria-label="`Remove tag ${tag.label}`"
        @click="removeAndFocus(index)"
        @keydown.left.prevent="focusTag(Math.max(0, index - 1))"
        @keydown.right.prevent="focusTag(Math.min(tags.length - 1, index + 1))"
        @keydown.backspace.prevent="removeAndFocus(index)"
        @keydown.delete.prevent="removeAndFocus(index)"
      >
        {{ tag.label }} <span aria-hidden="true">×</span>
      </button>

      <input
        :id="id"
        v-model="draft"
        type="text"
        role="combobox"
        :name="undefined"
        :required="required && tags.length === 0"
        :aria-autocomplete="visibleSuggestions.length ? 'list' : 'none'"
        :aria-controls="visibleSuggestions.length ? listboxId : undefined"
        :aria-expanded="visibleSuggestions.length > 0"
        :aria-activedescendant="activeSuggestion >= 0 ? `${id}-option-${activeSuggestion}` : undefined"
        :aria-describedby="[description ? hintId : '', errorMessage ? errorId : ''].filter(Boolean).join(' ') || undefined"
        :aria-invalid="errorMessage ? 'true' : undefined"
        :aria-errormessage="errorMessage ? errorId : undefined"
        placeholder="Add a tag"
        @keydown="onInputKeydown"
        @paste="onPaste"
      >
    </div>

    <ul
      v-if="visibleSuggestions.length"
      :id="listboxId"
      role="listbox"
      :aria-label="`${label} suggestions`"
    >
      <li
        v-for="(suggestion, index) in visibleSuggestions"
        :id="`${id}-option-${index}`"
        :key="suggestion"
        role="option"
        :aria-selected="index === activeSuggestion"
        @mousedown.prevent="selectSuggestion(suggestion)"
      >
        {{ suggestion }}
      </li>
    </ul>

    <p v-if="errorMessage" :id="errorId" role="alert">{{ errorMessage }}</p>

    <input
      v-for="tag in tags"
      :key="`${tag.id}-form`"
      type="hidden"
      :name="name"
      :value="tag.value"
    >
  </div>
</template>

Expected result: every entry path calls makeTag, so duplicates, invalid characters, and the maximum tag count are handled consistently.

Troubleshooting: do not use array index as the Vue key. Deleting or reordering tags can then reuse the wrong DOM element and make focus behavior unpredictable.

3. Support typing, paste, removal, and suggestions

The component above creates tags with Enter, comma, or semicolon. It keeps Tab for moving to the next control, which is usually less surprising in a form. It also splits paste input on commas, semicolons, whitespace, and new lines, then validates each resulting value individually.

The input handles two keyboard contexts:

  1. While typing: Arrow keys move the active suggestion, Enter selects it or creates the typed tag, Escape closes the suggestion state, and Backspace removes the last tag only when the draft is empty.
  2. While a tag button has focus: Left and Right move between tags. Backspace, Delete, and click remove the focused tag and move focus to a nearby tag or back to the input.

This follows common tags-input conventions documented by Reka UI, including clipboard input and directional navigation. If you need a headless, prebuilt foundation instead of maintaining these interactions yourself, Reka UI is a practical Vue option. Keep your own validation and server contract regardless of the library.

Expected result: pasting research, executive-summary\nlegal creates three validated tags, while pasting a duplicate creates no duplicate and shows feedback.

Troubleshooting: IME composition can use Enter internally. Check event.isComposing before treating Enter as a delimiter, as the example does.

4. Make labels, errors, and suggestions accessible

Accessibility is more than adding ARIA attributes. Begin with native controls: a real <label>, a real text <input>, real buttons for remove actions, and a real list for suggestions. Then add ARIA only where the interaction needs extra context.

Use these checks:

  • The label describes the input’s purpose, not its current tag values.
  • Helper text explains the entry rule, such as “Press Enter or comma to add a tag.”
  • Error text is connected to the input with aria-errormessage and announced with role="alert" when it changes.
  • The suggestion popup exposes aria-expanded, aria-controls, and an active option when it is visible.
  • Every remove button has a specific accessible name, such as “Remove tag legal-review.”

For descriptions and guidance, MDN’s aria-describedby reference distinguishes a concise accessible label from longer supporting text. Keep the visual instruction in the DOM and reference it from the input, rather than relying on placeholder text.

Visual workflow for entering, validating, suggesting, and submitting tags

Test with keyboard only before calling the component complete:

  1. Tab into the field and type a tag.
  2. Use comma and Enter to commit values.
  3. Paste a mixed delimiter list.
  4. Press Backspace in an empty input to remove the last tag.
  5. Open suggestions, move with Arrow keys, accept one with Enter, then dismiss with Escape.
  6. Tab through removal controls and confirm each has an understandable name.
  7. Trigger every validation failure and verify that focus stays useful for correction.

Expected result: a keyboard-only user can add, review, remove, and correct tags without a pointer.

Troubleshooting: when a suggestion popup opens, do not move browser focus into the listbox unless you are implementing that alternative pattern completely. Keeping focus in the input with aria-activedescendant makes ordinary text editing available.

5. Submit a stable form payload

The hidden inputs in the component submit repeated values with the same field name:

promptTags=research&promptTags=legal-review

On the server, read all values for that field. For JSON APIs, send the normalized array or map the full tag objects to the contract your endpoint expects.

async function submitPrompt() {
  const payload = {
    message: prompt.value.trim(),
    promptTags: tags.value.map((tag) => tag.value),
  }

  await fetch('/api/prompts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  })
}

Keep raw display labels in client state when they matter to users, but persist normalized values or stable IDs for filtering. That prevents case and whitespace variations from fragmenting your AI prompt analytics or saved filters.

When tags are one part of a larger configuration, use a schema-driven editing surface for the surrounding structured data. DOM Studio’s JSON Input shows how one schema can render regular form controls while retaining an editable raw JSON view. For the prompt itself, our rich AI textarea input guide explains how to add context and reliable submit states without turning the composer into a crowded settings panel.

Expected result: the client and server agree on whether tags are repeated form values, strings, or structured objects.

Troubleshooting: never trust client validation alone. Re-run your duplicate, count, authorization, and allowed-value checks on the server before using tags to drive an AI workflow.

6. Verify the finished tags input in realistic AI flows

Test the component with the data shapes your application actually sends:

  • Prompt labels: add research, concise, and customer-facing, then verify that duplicate casing is rejected.
  • Knowledge filters: paste a newline-separated list of source topics and confirm every allowed value reaches the request.
  • Recipients: use suggestions backed by approved contacts and ensure arbitrary strings cannot bypass identity validation.
  • Saved filters: reload a saved array of tag objects, remove one with the keyboard, and submit the changed array.

For a visual walkthrough of the core Vue component mechanics, this independent tutorial builds a reusable Vue tag input. Use it as a companion for the basics, then apply the focus, validation, and payload checks in this guide for production use.

FAQ

Is a tags input the same as a multi-select?

No. A multi-select usually restricts choices to known options. A tags input may allow arbitrary values, suggestions, or both. Choose based on your data contract, not visual preference.

Should Tab add a tag?

Usually no. Tab normally moves focus through a form, and preserving that behavior is easier to learn. Use Enter and a visible delimiter such as comma instead, unless your product has a strong, documented reason to change it.

How should we store tags for AI prompts?

Store a normalized string array when tags are free-form. Store stable IDs plus labels when each tag represents a controlled entity, permission, dataset, or filter option.

Do we need a library for a Vue tags input?

No. A custom component is reasonable when you need a small, exact interaction. Use a headless component library when your team would benefit from maintained keyboard and accessibility primitives, but still test the integrated experience in your application.

Build the smallest reliable multi-value field

Start with explicit validation, native controls, predictable keyboard behavior, and one stable submission format. Add suggestions only when they genuinely help users choose values. Once the interaction is reliable, you can place it beside DOM Studio’s editable form inputs and application primitives without losing control of the data model.