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.

bg-whitetext-sm
Playground.vuevue
Install
npm install @getdom/studio
vue
<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.

bg-whitep-6rounded-2xlshadow-smtext-zinc-900

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"
Install
npm install @getdom/studio
vue
<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.

Ada Lovelace

Select a known contact or type a new email address and press Enter.

Recipients

ada@example.com

Install
npm install @getdom/studio
vue
<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.

flex

Selected tokens survive new searches and pages.

0 of 0 results loaded. Scroll for more; search resets the pages.

Selected classes: flex

Install
npm install @getdom/studio
vue
<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

NameTypeDefaultDescription
v-modelArray<string | number>[]Selected tag values.
optionsArray<{ value, label, description?, group? }> | string[][]Available autocomplete options.
allowCustombooleanfalseAllow values that do not exist in options.
filterOptionsbooleantrueFilter options locally. Set false for server-filtered results.
loadingbooleanfalseShow an inline spinner while async options are being fetched.
emptyTextstring'No matches'Message shown when there are no matches.
placeholderstring'Add tags...'Input placeholder when no query is active.
maxOptionsnumber8Maximum local suggestions. Ignored when filterOptions=false so remote pages are not truncated.
hasMorebooleanfalseEnable scroll and keyboard paging through @load-more.
selectedOptionsDomOption[][]Resolved labels for initial values outside the loaded page.
tokenSeparatorsstring[][' ', ',']Characters used to split pasted text into multiple tags.
clearablebooleanfalseShow 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.