Vue 3 select, dropdown
& typeahead
A maintained Vue 3 fork of the classic vue-select — grouped options, a full WAI-ARIA 1.2 combobox, and native TypeScript types. Zero runtime dependencies, 7.42 KiB gzip.
Install, register, select
# Install
npm install @alosha/vue-select
// main.ts — register once
import { createApp } from 'vue'
import vSelect from '@alosha/vue-select'
import '@alosha/vue-select/dist/vue-select.css'
createApp(App).component('v-select', vSelect).mount('#app')
<!-- anywhere in your templates -->
<v-select v-model="selected" :options="['Canada', 'Mexico', 'United States']" />Everything the stalled beta line never finished
- Grouped optionsPass { label, options } group objects alongside flat options in the same array — group headers render as non-selectable, non-highlightable rows, and search filters within each group.
- WAI-ARIA 1.2 comboboxcombobox role and aria-expanded live on the search input, aria-activedescendant tracks the highlighted option, and focus stays put after selecting or pressing Escape — spec-correct, not an approximation.
- Clear, deselect & dropdown controlA clear slot and deselect slot customize the × controls, and openDropdown()/closeDropdown()/toggleDropdown() are exposed on the instance so a parent can drive the dropdown through a template ref.
- Native TypeScript typesHand-authored dist/vue-select.d.ts resolves automatically through the package's types/exports fields — no @types/vue-select needed, and installing it would only shadow the accurate bundled types.
- Positioning without a required dependencyDefault positioning is plain JS with zero dependencies; opt into appendToBody plus a calculatePosition built on @floating-ui/dom (replacing the old Popper.js docs example) for auto-flip, scroll-aware placement.
- Tagging & SSR out of the boxTurn on taggable to let users create new options from free text, and render the same component server-side — SSR support ships alongside the browser build.
- Zero deps, opt-in virtual scroll7.42 KiB gzip (ES) / 6.56 KiB (UMD) with no runtime dependencies; flip on virtual-scroll to window very large option lists so only visible rows hit the DOM (experimental, fixed row height).
Should you build your own select component?
WAI-ARIA combobox compliance is its own project
A filterable dropdown looks like a weekend component — until a screen reader gets involved.
What you would own building it yourself
- ARIA roles & states
combobox role, aria-expanded, aria-activedescendant and aria-selected all have to move in lockstep with keyboard and mouse interaction, or assistive tech reports the wrong state.
- Keyboard contract
Arrow keys, Escape, Tab and typeahead all need spec-correct focus handling — get the blur behavior wrong and Escape silently steals focus from the input.
- Ongoing spec drift
Browser behavior changes (like Chrome's keyboard-focusable scrollers) keep breaking ARIA-compliant comboboxes that were correct when they shipped.
With vue-select: vue-select implements the WAI-ARIA 1.2 combobox pattern out of the box — combobox role and aria-expanded on the input, aria-activedescendant for the highlighted option, and blur handling that keeps focus in the input after selection or Escape. You inherit a maintained implementation instead of re-deriving the spec.
Dropdown positioning breaks the moment it needs to escape overflow
A dropdown that renders inline collides with modals, tables and any container with overflow:hidden — so real apps need to append it to the body and calculate its position by hand.
What you would own building it yourself
- Overflow clipping
A dropdown menu inside a scrollable card or modal gets clipped unless it is portaled out and repositioned relative to the viewport.
- Position math
Flipping the menu above the input when there is no room below, and keeping its width in sync with the input, is nontrivial layout math to hand-roll.
- A whole extra dependency
Getting this right from scratch usually means reaching for a positioning library and a ResizeObserver, just to solve dropdown placement.
With vue-select: vue-select exposes appendToBody plus a calculatePosition hook, with a documented recipe wired to @floating-ui/dom — so the menu can escape overflow and reposition itself on scroll and resize without shipping a positioning library in the core bundle.
Build vs adopt: the engineering you'd take on
A rough estimate of the work to build and own an equivalent accessible, positioned, groupable select component in-house.
ARIA compliance + keyboard handling + positioning + grouping + tests — rough estimate
npm install — accessibility, positioning and grouping included
Production recipes
Bind a controlled multi-select to a form library without fighting v-model
The problem: A multi-select tags field needs to participate in form validation state (touched, errors, submit) instead of owning its own local ref.
<script setup lang="ts">
import { useField } from 'vee-validate'
const { value, errorMessage, handleBlur } = useField<string[]>('skills', undefined, {
initialValue: []
})
</script>
<template>
<v-select
v-model="value"
multiple
:options="['vue', 'react', 'svelte', 'angular']"
@search:blur="handleBlur"
/>
<span v-if="errorMessage" class="text-red-500 text-sm">{{ errorMessage }}</span>
</template>Why it works: modelValue/update:modelValue is a real Vue 3 v-model pair, so VeeValidate's useField() ref binds directly with no adapter layer. Forwarding the component's own search:blur event into handleBlur fires the field's touched/validate-on-blur behavior on the same interaction a native <select> would.
Build a country/region picker with grouped options
The problem: A signup form needs a country picker with regional sub-groups, and hand-rolling grouped `<optgroup>`-style rendering means reimplementing filtering and keyboard navigation per group.
<script setup lang="ts">
import { ref } from 'vue'
const country = ref<string | null>(null)
const options = [
{ label: 'Canada', code: 'CA' },
{ label: 'United States', code: 'US' },
{
label: 'Europe',
options: [
{ label: 'France', code: 'FR' },
{ label: 'Germany', code: 'DE' },
{ label: 'Netherlands', code: 'NL' }
]
}
]
</script>
<template>
<v-select
v-model="country"
:options="options"
:reduce="(option) => option.code"
label="label"
/>
</template>Why it works: Group objects ({ label, options }) mix freely with flat options in the same array, and group headers render as non-selectable, non-highlightable rows automatically — so a regionally-grouped picker needs no custom grouping logic, just a shaped options array. reduce keeps the bound value a plain country code instead of the whole option object.
Search a remote API with debounce instead of filtering a static list
The problem: An options list of thousands of records can't ship to the client up front — the dropdown needs to query an API as the user types, without firing a request on every keystroke.
<script setup lang="ts">
import { ref } from 'vue'
const options = ref<{ id: number; name: string }[]>([])
let debounceTimer: ReturnType<typeof setTimeout>
function onSearch(search: string, loading: (state: boolean) => void) {
clearTimeout(debounceTimer)
if (!search) return
loading(true)
debounceTimer = setTimeout(async () => {
const res = await fetch(`/api/repos?q=${encodeURIComponent(search)}`)
options.value = await res.json()
loading(false)
}, 300)
}
</script>
<template>
<v-select
:options="options"
:filterable="false"
label="name"
@search="onSearch"
/>
</template>Why it works: The search event hands you the raw query string plus a loading toggle, so debouncing and fetching are just your own setTimeout and fetch — no separate async mode to configure. filterable="false" turns off client-side filtering so the server's results are trusted as-is instead of being re-filtered against a label the API already matched.
Render a richer option row without losing keyboard accessibility
The problem: A staff picker needs to show a person's team next to their name, but replacing the default row risks breaking the keyboard-highlight and selection behavior that makes the dropdown accessible.
<script setup lang="ts">
import { ref } from 'vue'
interface Person { id: number; name: string; team: string }
const selected = ref<Person | null>(null)
const people: Person[] = [
{ id: 1, name: 'Amara Diallo', team: 'Platform' },
{ id: 2, name: 'Kenji Watanabe', team: 'Design' }
]
</script>
<template>
<v-select v-model="selected" :options="people" label="name">
<template #option="{ option }">
<div class="flex items-center justify-between gap-2">
<span>{{ option.name }}</span>
<span class="text-xs text-muted">{{ option.team }}</span>
</div>
</template>
<template #selected-option="{ option }">
{{ option.name }}
</template>
</v-select>
</template>Why it works: The option and selected-option slots receive slotProps.option — the original object by reference, not a cloned plain object — so a person's team renders next to their name without losing object identity. Keyboard navigation, aria-activedescendant and selection state are untouched by the slot — only what's painted changes, not how the combobox behaves.
Keep a 50,000-row dropdown fast with virtual scrolling
The problem: A product picker backed by a large catalog renders one DOM node per option by default — at tens of thousands of rows, opening the dropdown itself becomes the bottleneck.
<script setup lang="ts">
const bigList = Array.from({ length: 50000 }, (_, i) => `Product #${i + 1}`)
</script>
<template>
<v-select
:options="bigList"
virtual-scroll
:virtual-scroll-row-height="36"
/>
</template>Why it works: With virtual-scroll on, only the rows inside the visible viewport (plus a small buffer) are actually rendered, so 50,000 options never hit the DOM at once and opening the dropdown stays fast regardless of list size. It's foundation-only today (fixed row height, no grouped-option support yet), so keep virtualScrollRowHeight matched to your option row's real rendered height and verify it before shipping.
Built to pass a dependency review
| Metric / concern | What ships |
|---|---|
| Bundle size | 7.42 KiB gzip (ES) · 6.56 KiB (UMD) |
| Dependencies | 0 runtime deps |
| Accessibility | WAI-ARIA 1.2 combobox |
| Type safety | Ships .d.ts, no @types package |
| Test coverage | 246 passing tests · 24 files |
| Provenance | MIT fork, full attribution |
| Licensing | MIT |
Add vue-select to your project
- Custom slots, positioning or theming help for your design system
- Migration help moving off the abandoned vue-select@beta line
- Priority bug fixes and answers straight from the maintainer