Recipes
Integration patterns. Each one is self-contained — copy, paste, adapt.
Async search with TanStack Query
Pair @vue-select-plus/vue with @tanstack/vue-query for cached, cancellation-safe server search.
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useQuery, keepPreviousData } from '@tanstack/vue-query';
import { VSelect, type SelectOption } from '@vue-select-plus/vue';
const query = ref('');
const value = ref<string | null>(null);
const { data, isFetching } = useQuery({
queryKey: ['users', query],
queryFn: ({ signal }) =>
fetch(`/api/users?q=${encodeURIComponent(query.value)}`, { signal })
.then((r) => r.json()),
enabled: computed(() => query.value.length >= 2),
placeholderData: keepPreviousData,
staleTime: 30_000
});
const options = computed<SelectOption[]>(() =>
(data.value ?? []).map((u: { id: string; name: string }) => ({
value: u.id,
label: u.name
}))
);
</script>
<template>
<VSelect
v-model="value"
:options="options"
:loading="isFetching"
:filterable="false"
:min-search-length="2"
:search-debounce="250"
label="Assignee"
placeholder="Type at least 2 chars…"
searchable
clearable
@search="(q) => (query = q)"
/>
</template>Notes:
- TanStack passes a
signalinto your fetcher, so stale requests get cancelled automatically. keepPreviousDatastops the dropdown from flashing empty during the next fetch.staleTimeof 30 s dedupes repeat queries within the window.
VeeValidate
VeeValidate treats the model just like any other field. Disable our built-in HTML5 validation so the libraries don't fight:
<script setup lang="ts">
import { useField } from 'vee-validate';
import { VSelect } from '@vue-select-plus/vue';
const { value, errorMessage, handleBlur } = useField<string | null>('fruit', (v) =>
v ? true : 'Please pick a fruit'
);
</script>
<template>
<VSelect
v-model="value"
:options="fruits"
label="Fruit"
:error="errorMessage"
:validate-on-submit="false"
required
@close="handleBlur"
/>
</template>The component still:
- Sets
aria-invalid="true"andaria-required="true"from the props. - Renders the visible error message and links it via
aria-describedby.
It just won't block the native form submit — VeeValidate handles that.
FormKit
FormKit works the same way: wrap <VSelect> in a custom input or use it via FormKit.props.attrs.
<script setup lang="ts">
import { FormKit } from '@formkit/vue';
import { VSelect } from '@vue-select-plus/vue';
</script>
<template>
<FormKit
type="text"
name="fruit"
:validation="[['required']]"
validation-visibility="submit"
v-slot="{ node, value }"
>
<VSelect
:model-value="value"
@update:model-value="(v) => node.input(v)"
:options="fruits"
:error="node.context?.messages?.rule_required?.value"
:validate-on-submit="false"
label="Fruit"
required
/>
</FormKit>
</template>For tighter integration, wrap <VSelect> in a FormKit custom input. Validation, errors, value, and submit handling then flow through FormKit the normal way.
Inside a Modal
<VSelect> teleports to <body> by default, so it floats above most modals fine. The exception is a native <dialog>.showModal() — the dialog renders in the browser's top layer, and the body is behind it.
Two patterns:
Pattern A — teleport into the dialog
<script setup lang="ts">
import { ref } from 'vue';
import { VSelect } from '@vue-select-plus/vue';
const dialogRef = ref<HTMLDialogElement | null>(null);
function openDialog() {
dialogRef.value?.showModal();
}
</script>
<template>
<button @click="openDialog">Open</button>
<dialog ref="dialogRef">
<VSelect
v-model="value"
:options="options"
:teleport="dialogRef"
label="Pick one"
/>
</dialog>
</template>Pattern B — render inline
<VSelect :teleport="false" ... />Use this when the modal already handles layering and you want the dropdown in the same DOM subtree. Be aware that overflow: hidden on any ancestor will clip the menu.
Inside a Table cell
Sticky headers and overflow-x: auto typically clip dropdowns. The defaults already solve that — teleport to body, Floating UI's shift middleware, and a small size variant:
<td>
<VSelect v-model="row.status" :options="statuses" :item-height="32" size="sm" />
</td>Mobile-friendly setup
On touch devices:
- Set
searchable: false— typing on mobile is friction. - Teleport into a bottom-sheet rather than
<body>when using one. - Use
size="lg"for bigger touch targets.
<VSelect
v-model="value"
:options="options"
size="lg"
label="Country"
placeholder="Tap to choose"
/>If you do enable searchable, set inputmode="search" so iOS shows a "Search" key. For numeric values:
<VSelect searchable inputmode="numeric" ... />Object values (workaround until generics land)
SelectModelValue is string | number | …. To bind to objects, store an id in the model and look the object up locally:
<script setup lang="ts">
import { ref, computed } from 'vue';
import { VSelect, type SelectOption } from '@vue-select-plus/vue';
interface User { id: string; name: string; email: string }
const users: User[] = [
{ id: '1', name: 'Alice', email: 'a@x' },
{ id: '2', name: 'Bob', email: 'b@x' }
];
const selectedId = ref<string | null>(null);
const selected = computed(() => users.find((u) => u.id === selectedId.value));
const options = computed<SelectOption[]>(() =>
users.map((u) => ({ value: u.id, label: u.name }))
);
</script>
<template>
<VSelect v-model="selectedId" :options="options" label="User" />
<p v-if="selected">Email: {{ selected.email }}</p>
</template>A future major will add generic <TValue> support; until then, this indirection is the recommended pattern.
Two-step Backspace (confirm-before-remove)
The default Backspace shortcut removes the last tag immediately. For high-risk forms, gate it behind a confirmation by intercepting the update:modelValue event:
<script setup lang="ts">
const pendingRemoval = ref<string | number | null>(null);
const value = ref<string[]>(['apple', 'banana']);
function handleUpdate(next: string[]) {
if (next.length < value.value.length && pendingRemoval.value === null) {
const removed = value.value.find((v) => !next.includes(v))!;
pendingRemoval.value = removed;
// Re-confirm: revert for now
return;
}
pendingRemoval.value = null;
value.value = next;
}
</script>
<template>
<VSelect
:model-value="value"
@update:model-value="handleUpdate"
:options="fruits"
multiple
/>
<p v-if="pendingRemoval">
Press Backspace again to remove "{{ pendingRemoval }}",
or any other key to cancel.
</p>
</template>Custom keyboard shortcuts
Use a template ref to drive the component imperatively:
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { VSelect } from '@vue-select-plus/vue';
const selectRef = ref<InstanceType<typeof VSelect> | null>(null);
function onKey(e: KeyboardEvent) {
if (e.key === '/' && document.activeElement?.tagName !== 'INPUT') {
e.preventDefault();
selectRef.value?.open();
selectRef.value?.focus();
}
}
onMounted(() => window.addEventListener('keydown', onKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
</script>
<template>
<VSelect ref="selectRef" v-model="value" :options="options" searchable />
</template>The exposed methods are open, close, focus, and clear.
Theming with CSS-in-JS
If your design system uses CSS-in-JS (e.g. Vanilla Extract, Stitches, Pigment), override the variables on a wrapper:
<div :style="{ '--vs-primary': brand.primary, '--vs-radius': '12px' }">
<VSelect ... />
</div>Variables cascade naturally — no shadow DOM, no extra selector indirection.
Restoring the selected value when options arrive late
If your options are fetched asynchronously and the v-model is hydrated from the server with just an id, the trigger shows the raw id until the options arrive. Two fixes:
1. Use the value slot to render whatever you have on hand:
<VSelect v-model="userId" :options="users">
<template #value="{ value, label }">
<span>{{ label ?? `Loading ${value}…` }}</span>
</template>
</VSelect>2. Pre-seed options with the selected entry while fetching:
const options = computed(() => {
const fetched = data.value ?? [];
if (selectedUser.value && !fetched.some((u) => u.id === selectedUser.value!.id)) {
return [selectedUser.value, ...fetched];
}
return fetched;
});