Component
Vue Multi-Select and Tag Input
<DomTagCombobox>A multi-select autocomplete that turns selected or custom values into editable tags.
Playground
Try strict or custom tags
Tag combobox playground
Toggle allowCustom to switch between known-option-only and free-entry modes.
Properties
Control props
Selected tag values.
Placeholder shown when no query is active.
Autocomplete options. Strings are treated as both label and value.
Message shown when no option matches and custom values are not available.
Maximum matching suggestions to show.
Characters that split pasted text into tags.
Field props
Optional ID override. By default parent forms derive the input ID from the field path using underscores.
Local field name. Parent forms derive the full field path and native HTML name from the form hierarchy.
Visible field label.
Optional helper copy below the field.
Validation errors for this field.
Validators
No validators yet.
Validators attached to this field. Use functions in Vue code, or serializable records such as { name: "minLength", props: { min: 2 } } in generated schemas.
Render default field chrome, or hide chrome while keeping form state wiring.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { reactive } from 'vue';
import { DomTagCombobox } from '@getdom/studio';
const data = reactive({
"modelValue": [
"bg-white",
"text-sm"
],
"id": "",
"name": "",
"label": "Classes",
"description": "",
"placeholder": "Add a token",
"required": false,
"disabled": false,
"readOnly": false,
"invalid": false,
"errors": [],
"visible": true,
"validators": [],
"validateOnBlur": true,
"chrome": "field",
"options": [
{
"value": "text-sm",
"label": "text-sm",
"group": "Typography",
"description": ""
},
{
"value": "text-lg",
"label": "text-lg",
"group": "Typography",
"description": ""
},
{
"value": "text-zinc-900",
"label": "text-zinc-900",
"group": "Typography",
"description": ""
},
{
"value": "bg-white",
"label": "bg-white",
"group": "Background",
"description": ""
},
{
"value": "rounded-xl",
"label": "rounded-xl",
"group": "Borders",
"description": ""
},
{
"value": "shadow-sm",
"label": "shadow-sm",
"group": "Effects",
"description": ""
}
],
"allowCustom": true,
"filterOptions": true,
"loading": false,
"emptyText": "No matches",
"maxOptions": 8,
"tokenSeparators": [
" ",
","
],
"clearable": false,
"hasMore": false,
"selectedOptions": []
});
</script>
<template>
<DomTagCombobox
v-bind="data"
@update:modelValue="data.modelValue = $event"
/>
</template>Choosing a control
Multi-select or tag input?
Use Select with multiple for a compact picker of known options, such as reviewers, roles or projects. The closed field summarizes the selection; search lives inside the dropdown, where checkmarks let people select or deselect several items. It does not create new values.
Use Tag combobox (tag input) when each selected value should remain visible as a removable token, with typing and suggestions in the field itself. It suits recipients, labels and class names. Keep allowCustom off for known options only, or enable it for new values. Pasting can add several tokens using tokenSeparators; Backspace removes the last token when the query is empty.
Both use the same option normalization, selected-record retention, field state, pagination requests and loading/empty-state components. Both retain selections while searching or loading server pages. The different trigger, token editing and keyboard interactions are intentional; applications still own their data fetching.
Demo
Tailwind class tokens
Use permissive mode for Tailwind so known classes autocomplete, while arbitrary values like w-[100px] can still be added.
Try typing text- or add a custom arbitrary value like w-[100px].
Preview block
Selected classes are joined into one class string.
class="bg-white p-6 rounded-2xl shadow-sm text-zinc-900" npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomTagCombobox } from '@getdom/studio';
const classes = ref(['bg-white', 'p-6', 'rounded-2xl', 'shadow-sm', 'text-zinc-900']);
const tailwindOptions = [
{ value: 'bg-white', label: 'bg-white', group: 'Background' },
{ value: 'bg-zinc-950', label: 'bg-zinc-950', group: 'Background' },
{ value: 'bg-teal-500', label: 'bg-teal-500', group: 'Background' },
{ value: 'text-xs', label: 'text-xs', group: 'Typography' },
{ value: 'text-sm', label: 'text-sm', group: 'Typography' },
{ value: 'text-lg', label: 'text-lg', group: 'Typography' },
{ value: 'text-4xl', label: 'text-4xl', group: 'Typography' },
{ value: 'text-zinc-900', label: 'text-zinc-900', group: 'Typography' },
{ value: 'text-white', label: 'text-white', group: 'Typography' },
{ value: 'font-medium', label: 'font-medium', group: 'Typography' },
{ value: 'font-semibold', label: 'font-semibold', group: 'Typography' },
{ value: 'tracking-tight', label: 'tracking-tight', group: 'Typography' },
{ value: 'p-4', label: 'p-4', group: 'Spacing' },
{ value: 'p-6', label: 'p-6', group: 'Spacing' },
{ value: 'px-4', label: 'px-4', group: 'Spacing' },
{ value: 'py-2', label: 'py-2', group: 'Spacing' },
{ value: 'mt-6', label: 'mt-6', group: 'Spacing' },
{ value: 'rounded-md', label: 'rounded-md', group: 'Borders' },
{ value: 'rounded-2xl', label: 'rounded-2xl', group: 'Borders' },
{ value: 'border', label: 'border', group: 'Borders' },
{ value: 'border-zinc-200', label: 'border-zinc-200', group: 'Borders' },
{ value: 'shadow-sm', label: 'shadow-sm', group: 'Effects' },
{ value: 'shadow-xl', label: 'shadow-xl', group: 'Effects' },
{ value: 'flex', label: 'flex', group: 'Layout' },
{ value: 'grid', label: 'grid', group: 'Layout' },
{ value: 'items-center', label: 'items-center', group: 'Layout' },
{ value: 'justify-between', label: 'justify-between', group: 'Layout' },
];
const className = computed(() => classes.value.join(' '));
</script>
<template>
<div class="grid w-full gap-4">
<DomTagCombobox
v-model="classes"
:options="tailwindOptions"
:allow-custom="true"
label="Tailwind classes"
description="Try typing text- or add a custom arbitrary value like w-[100px]."
placeholder="Add a class"
>
<template #item="{ item, custom }">
<div class="flex min-w-0 items-center justify-between gap-3">
<span class="truncate font-mono text-xs">{{ custom ? item.value : item.label }}</span>
<span class="shrink-0 rounded-full bg-secondary px-2 py-0.5 text-[11px] text-muted-fg">
{{ custom ? 'custom' : item.group }}
</span>
</div>
</template>
</DomTagCombobox>
<div class="grid gap-2 rounded-2xl border border-border bg-secondary/40 p-4">
<div :class="className">
<p class="text-sm font-medium">Preview block</p>
<p class="mt-1 text-xs opacity-70">Selected classes are joined into one class string.</p>
</div>
<code class="block rounded-xl bg-canvas p-3 font-mono text-xs text-muted-fg ring-1 ring-border">
class="{{ className }}"
</code>
</div>
</div>
</template>
Demo
Email recipients
Use the same component for a To field: select contacts or type a new address manually.
Select a known contact or type a new email address and press Enter.
Recipients
ada@example.com
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomTagCombobox } from '@getdom/studio';
const recipients = ref(['ada@example.com']);
const contacts = [
{ value: 'ada@example.com', label: 'Ada Lovelace', description: 'ada@example.com' },
{ value: 'grace@example.com', label: 'Grace Hopper', description: 'grace@example.com' },
{ value: 'katherine@example.com', label: 'Katherine Johnson', description: 'katherine@example.com' },
{ value: 'margaret@example.com', label: 'Margaret Hamilton', description: 'margaret@example.com' },
];
</script>
<template>
<div class="grid w-full gap-4">
<DomTagCombobox
v-model="recipients"
:options="contacts"
:allow-custom="true"
label="To"
description="Select a known contact or type a new email address and press Enter."
placeholder="Add recipient"
>
<template #item="{ item, custom }">
<div class="min-w-0">
<span class="block truncate font-medium">{{ custom ? `Invite ${item.value}` : item.label }}</span>
<span class="block truncate text-xs text-muted-fg">{{ custom ? 'New address' : item.description }}</span>
</div>
</template>
<template #tag="{ item, value, remove }">
<span class="truncate">{{ item.description ? item.label : value }}</span>
<button
type="button"
class="-mr-1 inline-flex size-4 items-center justify-center rounded-full text-muted-fg hover:bg-canvas hover:text-canvas-fg"
:aria-label="`Remove ${value}`"
@click.stop="remove"
@mousedown.prevent
>
<svg viewBox="0 0 16 16" class="size-3" fill="none" aria-hidden="true">
<path d="M5 5l6 6M11 5l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
</button>
</template>
</DomTagCombobox>
<div class="rounded-2xl border border-border bg-secondary/40 p-4 text-sm">
<p class="font-medium text-canvas-fg">Recipients</p>
<p class="mt-1 break-all font-mono text-xs text-muted-fg">{{ recipients.join(', ') || 'None' }}</p>
</div>
</div>
</template>
Demo
Server-loaded options
@query lets the parent fetch options. Set filterOptions=false when the server already returns the matching rows.
Selected tokens survive new searches and pages.
0 of 0 results loaded. Scroll for more; search resets the pages.
Selected classes: flex
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomTagCombobox } from '@getdom/studio';
import { usePagedLookup } from '../../_shared/usePagedLookup.js';
import LookupStatus from '../../_shared/LookupStatus.vue';
const classes = ref(['flex']);
const { options, loading, hasMore, error, total, search, loadMore } = usePagedLookup('classes');
</script>
<template>
<div class="grid w-full max-w-md gap-3">
<DomTagCombobox
v-model="classes"
:options="options"
:loading="loading"
:has-more="hasMore"
:filter-options="false"
:allow-custom="true"
label="Server-loaded classes"
description="Selected tokens survive new searches and pages."
placeholder="Search or browse classes"
@query="search"
@load-more="loadMore"
>
</DomTagCombobox>
<LookupStatus :error="error" :count="options.length" :total="total" @retry="loadMore" />
<p class="text-xs text-muted-fg">Selected classes: <code>{{ classes.join(' ') || 'None' }}</code></p>
</div>
</template>
Behaviour
Selection, search and remote pages
A selected value is not a search filter. Opening shows the loaded options and a checkmark on matching selections. Only typing filters; custom item/option slots receive query and selected.
Set :filter-options="false" for server-ranked results. Handle @query to reset the cursor and replace options, then @load-more to append the next page. Pass loading and hasMore. Scrolling, keyboard navigation at the last row, and the Load more button request another page.
The examples call /api/form-options, a real HTTP endpoint serving synthetic records. The shared example loader debounces queries, aborts old requests, ignores stale responses, deduplicates pages and exposes a Retry button. Applications own their endpoint, authorization and cursor protocol; the controls do not fetch data themselves.
Selected labels are retained when a page is replaced. For Select, Combobox and Tag combobox, selectedOptions can supply initial records that are not loaded yet. Autocomplete stores the label as free text. Select opens near the selected row when it is loaded; a remote selection on a later page requires an application-owned lookup or seek endpoint. Multi-select opens at the beginning.
Props
Props
| Name | Type | Default | Description |
|---|---|---|---|
| v-model | Array<string | number> | [] | Selected tag values. |
| options | Array<{ value, label, description?, group? }> | string[] | [] | Available autocomplete options. |
| allowCustom | boolean | false | Allow values that do not exist in options. |
| filterOptions | boolean | true | Filter options locally. Set false for server-filtered results. |
| loading | boolean | false | Show an inline spinner while async options are being fetched. |
| emptyText | string | 'No matches' | Message shown when there are no matches. |
| placeholder | string | 'Add tags...' | Input placeholder when no query is active. |
| maxOptions | number | 8 | Maximum local suggestions. Ignored when filterOptions=false so remote pages are not truncated. |
| hasMore | boolean | false | Enable scroll and keyboard paging through @load-more. |
| selectedOptions | DomOption[] | [] | Resolved labels for initial values outside the loaded page. |
| tokenSeparators | string[] | [' ', ','] | Characters used to split pasted text into multiple tags. |
| clearable | boolean | false | Show a clear-all button when tags are selected. |
Events
update:modelValueFired with the next selected values array.queryFired as the user types.addFired when a tag is added.removeFired when a tag is removed.selectFired when an option or custom value is committed.changeFired after the selected values array changes.load-moreRequests the next page while preserving existing selected values.
Keyboard
- Up / DownMove the active suggestion.
- EnterCommit the active suggestion, or create a custom tag when allowed.
- BackspaceRemove the last tag when the query is empty.
- PasteSplit whitespace or comma-separated text into multiple tags.
- EscClose the suggestion list.