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.
<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.
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" <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.
Select a known contact or type a new email address and press Enter.
Recipients
ada@example.com
<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.
The component emits @query. The parent fetches options and passes the server result back in.
flex
<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
| 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 matching suggestions to show. |
| 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.
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.