Open source · Free npm package

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.

.single .multi .tags .asyncone component, every select pattern
Latest npm versionMinified + gzipped bundle sizeTypeScript types includedMonthly npm downloadsLicense

Install, register, select

Vue 3 removed the global Vue object, so registration happens once on the app instance — then it behaves like any other v-model input.
# 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

This fork ships the accessibility, typing and stability work the Vue 3 line needed to leave beta.
  • Grouped options
    Pass { 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 combobox
    combobox 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 control
    A 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 types
    Hand-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 dependency
    Default 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 box
    Turn 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 scroll
    7.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?

Why Vue 3 teams reach for a maintained package instead of hand-rolling an accessible combobox for every form.

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.

Build & own an accessible combobox yourself~8 dev-days

ARIA compliance + keyboard handling + positioning + grouping + tests — rough estimate

@alosha/vue-select~0 days

npm install — accessibility, positioning and grouping included

Production recipes

Real problems Vue 3 forms hit — solved with the published API.

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

The questions a CTO asks before adding a package to production — answered up front.
Metric / concern What ships
Bundle size7.42 KiB gzip (ES) · 6.56 KiB (UMD)
Dependencies0 runtime deps
AccessibilityWAI-ARIA 1.2 combobox
Type safetyShips .d.ts, no @types package
Test coverage246 passing tests · 24 files
ProvenanceMIT fork, full attribution
LicensingMIT

Add vue-select to your project

vue-select is free and MIT-licensed. When you need a hosted feature, a custom build, or a fast answer from the person who wrote it, there is a paid path — backed by the founder, not a ticket queue.
  • 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