Component

Tag combobox

<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
<script setup>
import { reactive } from 'vue';
import { DomTagCombobox } from '@getdom/studio/vue';

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
	});
</script>

<template>
	<DomTagCombobox
		v-bind="data"
		@update:modelValue="data.modelValue = $event"
	/>
</template>

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"
TailwindClasses.vuevue
<script setup>
import { computed, ref } from 'vue';
import { DomTagCombobox } from '@getdom/studio/vue';

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-background 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

EmailRecipients.vuevue
<script setup>
import { ref } from 'vue';
import { DomTagCombobox } from '@getdom/studio/vue';

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-background hover:text-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-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

The component emits @query. The parent fetches options and passes the server result back in.

flex

ServerLoadedClasses.vuevue
<script setup>
import { ref } from 'vue';
import { DomTagCombobox } from '@getdom/studio/vue';
import { searchTailwindClasses } from '../../_shared/serverLookup.js';

const classes = ref(['flex']);
const loading = ref(false);
const options = ref([]);
let requestId = 0;

async function searchClasses(query) {
	const currentRequest = ++requestId;
	if (!query.trim()) {
		options.value = [];
		loading.value = false;
		return;
	}

	loading.value = true;
	const results = await searchTailwindClasses(query);
	if (currentRequest !== requestId) return;
	options.value = results;
	loading.value = false;
}
</script>

<template>
	<div class="grid w-full gap-3">
		<DomTagCombobox
			v-model="classes"
			:options="options"
			:loading="loading"
			:filter-options="false"
			:allow-custom="true"
			label="Server-loaded classes"
			description="The component emits @query. The parent fetches options and passes the server result back in."
			placeholder="Type a class prefix"
			@query="searchClasses"
		/>
		<p class="rounded-2xl border border-border bg-secondary/40 p-3 font-mono text-xs text-muted-fg">
			{{ classes.join(' ') }}
		</p>
	</div>
</template>

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 matching suggestions to show.
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.

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.