npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

vue-ui-select

v2.1.0

Published

Vue 3 drop-in replacement for AngularJS ui-select — search, multi-select, tagging, grouping, theming

Readme

vue-ui-select

A production-grade Vue 3 select component — drop-in replacement for AngularJS ui-select.

Search, multi-select, tagging, grouping, keyboard navigation, ARIA accessibility, teleport, and four built-in themes.

Live Demo & Playground

Why this exists

The AngularJS ui-select directive had a quality that's hard to find in modern component libraries: you could build complex select scenarios — multi-select, grouping, async search, tagging, custom rendering — entirely in the template, without writing a single extra line of JavaScript. Slots and props were expressive enough that your component code stayed clean and focused on business logic, never polluted with select-widget plumbing.

This library brings that same philosophy to Vue 3. The richer the slot and prop API, the less JavaScript you write. Localization, custom layouts, async data, tag creation — it's all configurable from the template. If you ever felt nostalgic about how productive ui-select made you, this is for you.

Features

  • Single & multi-select — strings, objects, or dictionaries
  • Type-ahead search — client-side filtering with configurable search fields
  • Remote search — debounce the search event and feed items from an API
  • Grouping — group-by string property or function, with group-filter
  • Tagging — create new items on the fly, with token separators
  • Keyboard navigation — Arrow keys, Enter, Escape, Tab, Backspace
  • ARIA accessible — combobox role, aria-expanded, aria-activedescendant, listbox/option roles, named group headers and tag controls, and a visible keyboard focus ring
  • Teleport — append-to-body to escape overflow containers
  • Dropdown positioning — auto, up, or down
  • 4 built-in themes — Tailwind CSS, Bootstrap, Select2, Selectize
  • Dark mode — Tailwind theme supports .dark / [data-theme="dark"]
  • App-wide defaults — set searchEnabled, clearable, appendToBody, closeOnSelect once via plugin options
  • Form-validator friendly — dispatches native input / blur on the host so any DOM-event validator (oop-validator, VeeValidate, hand-rolled) works without coupling. input marks user edits only, and a field is not marked touched merely because its dropdown was opened
  • TypeScript — full type definitions included
  • Composable architecture — every feature is a standalone composable

Installation

npm install vue-ui-select

Upgrading to 2.0

Most applications need no code changes. Read the first section and skim the rest.

The one you are most likely to notice

A plugin-wide clearable: true now actually works. The prop was declared in a way that stopped it ever reaching the plugin default, so the setting did nothing. If you configured it, every select that does not pass clearable itself will now show a × once it has a value. That may be a lot of clear buttons appearing at once. If you would rather not have them, set clearable: false in the plugin defaults — instances that pass the prop explicitly are unaffected.

clearable also works in multi-select now, where it previously did nothing. It clears every unlocked tag at once.

Vue 3.5 is now the minimum

The peer range was ^3.3.0 and is now ^3.5.0. Vue 3.5 introduced useId(), which is the only way to generate ids that match between a server render and its hydration; before it there was no SSR-safe option at all. If you are on 3.3 or 3.4 and not server-rendering, 1.x continues to work.

If you use an object (dictionary) source

Event payloads and every consumer callback now receive the value rather than the internal { key, value } wrapper. Drop the .value:

<!-- 1.x -->
:group-by="item => item.value.tier"
@select="e => save(e.item.value)"

<!-- 2.0 -->
:group-by="item => item.tier"
@select="e => save(e.item)"

track-by and bind-property are unchanged. Array sources are unaffected, with one exception: an array whose items happen to have exactly the keys key and value used to be mistaken for a dictionary wrapper and silently unwrapped. Such items now round-trip whole.

Smaller behaviour changes

  • @paste on <ui-select> now reaches your handler. It was previously declared as a component event, which stripped it from attribute fallthrough while never firing. If you have one, it will start running.
  • A multi-select no longer opens on focus. Tabbing into it leaves it closed; clicking or typing opens it. This is what stopped the dropdown reopening every time the browser window regained focus.
  • The synthetic input event fires only for user edits, matching a native control, which does not fire input when code assigns to .value. A validator that relied on it firing for programmatic writes needs another trigger.
  • Clearing respects lock-choice. Locked items survive the × control, Backspace and clear(), and the clear payload lists only what was really removed.
  • Re-selecting an already-selected item emits nothing. It previously emitted update:modelValue and select with an unchanged model.
  • A prefixed kebab alias is now usable. prefix: 'My' registered Myui-select, which no template can write; it now registers my-ui-select.
  • Disabled selects leave the tab order, and keyboard focus now draws a visible outline in all four themes.
  • aria-multiselectable moved from the combobox to the listbox, where it is valid. The root also takes an aria-label from placeholder when you supply no label of your own.
  • A multi-select at its limit now disables the options it cannot take, instead of ignoring clicks in silence, and emits limit-reached if something picks anyway. Already-selected options stay enabled. See Limiting how many can be picked.

If you style the internals

The minimum-length prompt now renders inside the choices container rather than replacing it, so that aria-controls always names an element that exists. CSS treating .ui-select-choices__minimum-length as a sibling of .ui-select-choices needs updating. The class, the data attribute and the slot are unchanged.

Types

UiSelectEmits gained clear and limit-reached, and lost paste. UiSelectProps gained debounce, which existed at runtime but could not be typed. ClearEventPayload and LimitReachedPayload are new exports.


Quick Start

Plugin (recommended)

import { createApp } from 'vue'
import UiSelectPlugin from 'vue-ui-select'
import 'vue-ui-select/themes/tailwind.css'

const app = createApp(App)
app.use(UiSelectPlugin)
app.mount('#app')

Plugin options

app.use(UiSelectPlugin, {
  theme: 'tailwind',        // 'tailwind' | 'bootstrap' | 'select2' | 'selectize'
  prefix: '',               // optional component name prefix (e.g. 'My' → MyUiSelect)
  defaults: {
    searchEnabled: false,   // turn search off everywhere by default
    clearable: true,        // show the clear (×) on every instance
    appendToBody: true,     // teleport every dropdown to <body>
    closeOnSelect: false,   // keep dropdown open after each pick
    removeSelected: false,  // keep the chosen option in the list, like the original ui-select
  },
  messages: {               // the default empty-state copy; see "No choice (empty state)"
    noOptions: 'No options',
    noResults: 'No results',
    noOtherOptions: 'No other options',
    allSelected: 'All options selected',
  },
})

Per-instance props always win: <ui-select :search-enabled="true"> overrides the plugin default. Defaults are applied only when the prop is omitted, so existing code that doesn't pass these props will pick up the global value automatically.

Individual imports

import { UiSelect, UiSelectMatch, UiSelectChoices } from 'vue-ui-select'

Mini Tutorial

Everything is built with three tags. Think of them like a sandwich:

<ui-select>            ← the wrapper (the bread)
  <ui-select-match>    ← what the user sees when something is picked (top slice)
  <ui-select-choices>  ← the dropdown list to pick from (bottom slice)
</ui-select>

That's it. Every feature in this library is just props and slots on these three tags.


Step 1 — The simplest possible select

Pick one thing from a list of strings:

<template>
  <ui-select v-model="color">
    <ui-select-match placeholder="Pick a color..." />
    <ui-select-choices :items="['Red', 'Green', 'Blue']">
      <template #choice="{ item }">{{ item }}</template>
    </ui-select-choices>
  </ui-select>
</template>

<script setup>
import { ref } from 'vue'
const color = ref(null)
</script>

What's happening:

  • v-model="color" — the selected value goes here
  • :items — your list of options
  • #choice — how each option looks in the dropdown

Step 2 — Working with objects

Real apps use objects, not strings. Just tell the component which field is the ID:

<template>
  <ui-select v-model="person">
    <ui-select-match placeholder="Pick a person...">
      <template #default="{ selected }">{{ selected.name }}</template>
    </ui-select-match>
    <ui-select-choices :items="people" :track-by="'id'">
      <template #choice="{ item }">{{ item.name }}</template>
    </ui-select-choices>
  </ui-select>
</template>

<script setup>
import { ref } from 'vue'
const person = ref(null)
const people = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
  { id: 3, name: 'Charlie' },
]
</script>

What changed:

  • :track-by="'id'" — tells the component how to compare objects
  • #default="{ selected }" — controls how the picked item displays (not the dropdown — the input area)

Step 3 — Add search

Just add :search-fields and you get type-ahead filtering for free:

<ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']">
  <template #choice="{ item }">{{ item.name }}</template>
</ui-select-choices>

Want to search multiple fields? Pass them all in: :search-fields="['name', 'email', 'city']"

Object items are only filtered by the fields you declare — with none declared they pass through untouched (that's what makes API-backed search work with zero config). Plain strings and numbers always match by value, no config needed.


Step 4 — Multi-select

Add :multiple="true" to the wrapper. Now v-model becomes an array:

<template>
  <ui-select v-model="selected" :multiple="true">
    <ui-select-match placeholder="Add people...">
      <template #tag="{ item, removeItem }">
        {{ item.name }}
        <button @click="removeItem(item)">&times;</button>
      </template>
    </ui-select-match>
    <ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']">
      <template #choice="{ item }">{{ item.name }}</template>
    </ui-select-choices>
  </ui-select>
</template>

<script setup>
import { ref } from 'vue'
const selected = ref([])
</script>

What changed:

  • :multiple="true" — now you can pick many items
  • #tag replaces #default — each selected item shows as a tag/chip
  • removeItem(item) — call this to remove a tag

Step 5 — Highlight search matches

Use the highlighted() function from the slot to bold the matching text:

<ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']">
  <template #choice="{ item, search, highlighted }">
    <span v-html="highlighted(item.name, search)"></span>
  </template>
</ui-select-choices>

Type "ali" and you'll see "Alice" with the match bolded.


Step 6 — Grouping

Group items by a property:

<ui-select-choices :items="people" :track-by="'id'" :group-by="'country'">
  <template #group-header="{ groupName }">🌍 {{ groupName }}</template>
  <template #choice="{ item }">{{ item.name }}</template>
</ui-select-choices>

Step 7 — Checkbox multi-select

Show checkboxes next to each item — great for "pick many without closing the dropdown":

<ui-select v-model="selected" :multiple="true" :remove-selected="false" :close-on-select="false">
  <ui-select-match placeholder="Pick colors...">
    <template #tag="{ item, removeItem }">
      {{ item }} <button @click="removeItem(item)">&times;</button>
    </template>
  </ui-select-match>
  <ui-select-choices :items="['Red', 'Green', 'Blue']" :show-checkboxes="true">
    <template #choice="{ item }">{{ item }}</template>
  </ui-select-choices>
</ui-select>

Key props for checkboxes:

  • :show-checkboxes="true" — render checkboxes
  • :remove-selected="false" — keep checked items visible in the dropdown
  • :close-on-select="false" — don't close after each pick

Cheat sheet

| I want to... | What to add | |---|---| | Pick one item | <ui-select v-model="val"> | | Pick many items | add :multiple="true" | | Search/filter | add :search-fields="['name']" on choices | | Use objects | add :track-by="'id'" on choices | | Bold search matches | use highlighted(item.name, search) in #choice slot | | Group items | add :group-by="'country'" on choices | | Checkboxes | add :show-checkboxes="true" on choices | | Tagging (create new items) | add :tagging="true" on wrapper | | Clear button | add :clearable="true" on wrapper | | Empty state message | add <ui-select-no-choice>No results</ui-select-no-choice> |

Usage

Single select

<template>
  <ui-select v-model="selected" :clearable="true">
    <ui-select-match placeholder="Pick a person...">
      <template #default="{ selected }">{{ selected?.name }}</template>
    </ui-select-match>
    <ui-select-choices
      :items="people"
      :track-by="'id'"
      :search-fields="['name', 'email']"
    >
      <template #choice="{ item, search, highlighted }">
        <span v-html="highlighted(item.name, search)"></span>
      </template>
    </ui-select-choices>
  </ui-select>
</template>

Multi-select

<template>
  <ui-select v-model="selected" :multiple="true" :clearable="true">
    <ui-select-match placeholder="Add people...">
      <template #tag="{ item, removeItem }">
        <span class="chip">
          {{ item.name }}
          <button @click="removeItem(item)">&times;</button>
        </span>
      </template>
    </ui-select-match>
    <ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']">
      <template #choice="{ item }">{{ item.name }}</template>
    </ui-select-choices>
  </ui-select>
</template>

Limiting how many can be picked

limit caps a multi-select. Once the cap is reached the remaining options render as disabled, so the list explains itself instead of quietly ignoring clicks. Keyboard navigation skips them too.

Options already in the selection stay enabled — they are the way back down. Remove one and everything becomes selectable again.

<ui-select v-model="chosen" :multiple="true" :limit="3" @limit-reached="onLimit">
  <ui-select-match placeholder="Pick up to three..." />
  <ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']">
    <template #choice="{ item }">{{ item.name }}</template>
  </ui-select-choices>
</ui-select>
function onLimit({ item, limit }) {
  // `item` is whatever was refused: one of your options, or — under tagging —
  // the raw text that would have become a tag.
  toast(`You can only pick ${limit}.`)
}

Style the refused rows with .ui-select-choices__row--disabled, the same class disable-choice already uses.

limit-reached covers the cases a disabled row cannot. Clicking an option at the limit is impossible, because the row is disabled, so the event does not fire for that. It fires when something picks by other means:

  • the exposed select() method;
  • creating a tag — by Enter, by a token key, or by pasting text containing one. Tagging has no row to grey out, so the event is the only signal. It reports once per attempt, not once per token, and the text stays in the search box.

With tagging on, the "(new)" row also hides itself at the limit, for the same reason the options grey out.

Tagging

<ui-select v-model="tags" :multiple="true" :tagging="true" :tagging-tokens="[',']">
  <ui-select-match placeholder="Type to add tags...">
    <template #tag="{ item, removeItem }">
      {{ item }} <button @click="removeItem(item)">&times;</button>
    </template>
  </ui-select-match>
  <ui-select-choices :items="suggestions">
    <template #choice="{ item }">{{ item }}</template>
  </ui-select-choices>
</ui-select>

Checkbox multi-select

<template>
  <ui-select v-model="selected" :multiple="true" :remove-selected="false" :close-on-select="false">
    <ui-select-match placeholder="Pick people...">
      <template #tag="{ item, removeItem }">
        {{ item.name }} <button @click="removeItem(item)">&times;</button>
      </template>
    </ui-select-match>
    <ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']" :show-checkboxes="true">
      <template #choice="{ item }">{{ item.name }}</template>
    </ui-select-choices>
  </ui-select>
</template>

Tip: Pair :show-checkboxes="true" with :remove-selected="false" and :close-on-select="false" so selected items stay visible with checked checkboxes and the dropdown remains open for multiple picks.

Grouping

<ui-select-choices
  :items="people"
  :track-by="'id'"
  :group-by="'country'"
  :group-filter="['US', 'UK']"
  :search-fields="['name']"
>
  <template #group-header="{ groupName }">{{ groupName }}</template>
  <template #choice="{ item }">{{ item.name }}</template>
</ui-select-choices>

Object source (dictionary)

Pass a plain object and each entry becomes a choice. The choice slot receives the value as item, plus the key alongside it:

<ui-select-choices :items="{ r: 'Red', g: 'Green', b: 'Blue' }">
  <template #choice="{ item, key }">{{ key }}: {{ item }}</template>
</ui-select-choices>

v-model receives the value ('Red'). To bind the key instead, put bind-property="key" on <ui-select-choices>:

<ui-select v-model="colour">           <!-- colour === 'r' -->
  <ui-select-choices :items="colours" bind-property="key" />
</ui-select>

Everything you write receives the value, never the internal wrapper. That holds for the event payloads and for group-by, disable-choice, sort-fn, filter-fn and lock-choice:

<ui-select-choices
  :items="roles"
  :group-by="role => role.tier"          <!-- the value, not { key, value } -->
  :disable-choice="role => role.retired"
/>

The two exceptions are track-by and bind-property, which address the wrapper on purpose — that is what makes bind-property="key" work.

Append to body (teleport)

<ui-select v-model="val" :append-to-body="true">
  <!-- dropdown renders to document.body, escaping overflow containers -->
</ui-select>

A teleported dropdown is no longer inside the containers its control lives in, so it does three things deliberately that it would otherwise get from the browser for free:

  • It tracks the control as the page or any inner container scrolls, and it is positioned correctly whether <body> is static, positioned, or transformed, and in right-to-left layouts.
  • It hides itself while its control is scrolled out of a clipping container, instead of floating over whatever now occupies that space. It reappears when the control does, and stays open throughout — the search term and the highlight survive.
  • It forwards wheel scrolling it cannot use back to the containers the control belongs to, walking outwards past any that have reached their end, so the page never freezes while it is open.

No choice (empty state)

An empty list always explains itself. With no <ui-select-no-choice> of your own, the dropdown shows one of four messages, chosen by why the list is empty:

| Reason | Single mode | Multiple mode | |--------|-------------|---------------| | The source has no options | No options | No options | | Nothing matched the search | No results | No results | | Every match is already chosen (remove-selected hid it) | No other options | All options selected |

The third row is the one edit screens hit constantly: a select whose only option is already the value. It used to open onto a blank panel. Change the copy once for the whole app through the plugin's messages (see Plugin options).

For markup, or per-instance text, supply the slot. It receives the same reason, plus the trimmed search term and multiple:

<ui-select v-model="val">
  <ui-select-match placeholder="Select...">...</ui-select-match>
  <ui-select-choices :items="items">...</ui-select-choices>
  <ui-select-no-choice v-slot="{ reason, search }">
    <span v-if="reason === 'no-results'">Nothing matches “{{ search }}”</span>
    <span v-else-if="reason === 'all-selected'">That is the only option</span>
    <span v-else>Nothing to choose from</span>
  </ui-select-no-choice>
</ui-select>

The message never shows while loading, below minimum-input-length (the prompt shows instead), or while a tagging "(new)" row is on offer, since each of those already says what is going on.

If you would rather the chosen option simply stayed in the list — which is what the original AngularJS ui-select did in single mode, and what most other selects do — set remove-selected to false, per instance or app-wide via defaults.removeSelected.

Remote search (API-backed)

The rule that decides everything here: debounce delays only the search event — filtering is never debounced. So local lists need nothing, and remote lists need the four pieces below.

<template>
  <ui-select
    v-model="company"
    search-enabled
    :debounce="300"
    :loading="searching"
    @search="onSearch"
  >
    <ui-select-match placeholder="Search by company name or code">
      <template #default="{ selected }">{{ selected?.name ?? '' }}</template>
    </ui-select-match>

    <!-- No search-fields: object items with none declared are NOT re-filtered
         client-side, so the API's rows render exactly as returned. -->
    <ui-select-choices
      :items="companies"
      :track-by="'id'"
      :minimum-input-length="MIN_SEARCH_LENGTH"
    >
      <template #choice="{ item }">{{ item?.name }}</template>
    </ui-select-choices>

    <ui-select-no-choice>No companies found</ui-select-no-choice>
  </ui-select>
</template>

<script setup>
import { ref } from 'vue'

const MIN_SEARCH_LENGTH = 2

const company = ref(null)
const companies = ref([])
const searching = ref(false)

let latestSearchId = 0

async function onSearch(term) {
  if (!term || term.length < MIN_SEARCH_LENGTH) {
    companies.value = []
    return
  }

  const searchId = ++latestSearchId
  searching.value = true

  try {
    const { data } = await api.searchCompanies(term)
    // A newer search has already gone out — this response is stale.
    if (searchId !== latestSearchId) return
    companies.value = data ?? []
  } catch (error) {
    if (searchId !== latestSearchId) return
    companies.value = []
  } finally {
    if (searchId === latestSearchId) searching.value = false
  }
}
</script>

Why no search-fields here? The component only filters what you tell it to filter: object items with no declared search-fields (and no filter-fn) pass through as-is, because the assumption is that whoever supplied the items — your API — already filtered them. Declare search-fields and you opt back into client-side filtering. One caveat: primitive items always filter by value, so a remote list of plain strings still needs :filter-fn="() => true" to keep server rows the term doesn't literally contain.

Keep the request-id guard even with debounce. Debouncing collapses one burst of keystrokes into a single request; it does not order responses. Type hil, pause, type ton, and two requests are in flight — the first can still land last.

An empty term arrives immediately, not after the delay: clearing the input or closing the dropdown emits search with '' straight away, which is your cue to drop the results. The early return above handles it.

Local search needs none of this — give <ui-select-choices> a search-fields array and filtering happens in the component. debounce is only worth adding offline if your own search handler does something costly (writing a query param, analytics, a store round-trip).

Coming from AngularJS? refresh is the search event and refresh-delay is the debounce prop — which now sits on <ui-select>, next to the event it modifies.

API Reference

<ui-select> Props

Props marked † can also be set as plugin-wide defaults — see Plugin options.

| Prop | Type | Default | Description | |------|------|---------|-------------| | v-model | any | — | Selected value (object, primitive, or array for multi) | | multiple | boolean | false | Enable multi-select mode | | disabled | boolean | false | Disable the component | | clearable † | boolean | false | Show the clear (×). In multi-select it clears every unlocked tag at once | | search-enabled † | boolean | true | Enable search/filter | | reset-search-input | boolean | true | Clear search on close | | close-on-select † | boolean | true | Close dropdown after selection | | append-to-body † | boolean | false | Teleport dropdown to <body> | | position | 'auto' \| 'up' \| 'down' | 'auto' | Dropdown position | | theme | string | 'tailwind' | Theme name | | placeholder | string | '' | Placeholder text | | tagging | boolean \| Function | false | Enable tagging, or provide a factory function | | tagging-label | string \| false | '(new)' | Label for new tag row | | tagging-tokens | string[] | [','] | Characters that trigger tag creation | | limit | number | — | Max selections in multi mode. Once reached, the unselected options are disabled; see Limiting how many can be picked | | remove-selected | boolean | true | Hide chosen items from the dropdown. Also settable app-wide via the plugin's defaults.removeSelected. The original AngularJS ui-select only did this in multiple mode; set it to false to match | | loading | boolean | false | Show loading spinner | | debounce | number | 0 | Milliseconds to wait after typing stops before emitting search. Filtering stays instant | | autofocus | boolean | false | Focus on mount | | input-id | string | — | Custom ID for the search input. See the labelling note below | | lock-choice | Function | — | Predicate: locked items cannot be removed, by the tag ×, by Backspace, by clear(), or by the clear control |

Server-side rendering

The component renders on the server without special handling, and hydrates cleanly.

A multi-select always renders its search input, and that input carries a generated id. The id comes from Vue's own useId(), which is deterministic per application, so the server and the client agree. (This is why the peer range requires Vue 3.5: useId() does not exist before it, and there is no SSR-safe id to fall back on.)

A single-select renders no generated id at all while its dropdown is closed, which is how the server always renders it, so it is unaffected either way.

If you would rather pin the id yourself — to pair it with a <label for>, say — pass input-id and it is used verbatim:

<ui-select v-model="countries" :multiple="true" input-id="country-select"> ... </ui-select>

Labelling a select

In multi-select the search input is always rendered, so input-id pairs with a <label for> in the usual way.

In single-select the search input lives inside the dropdown and therefore only exists while the dropdown is open. A <label for="..."> pointing at it dangles whenever the select is closed. Label the component itself instead:

<span id="country-label">Country</span>
<ui-select v-model="country" aria-labelledby="country-label"> ... </ui-select>

aria-label works too. With neither, the component falls back to using placeholder as the accessible name, so a select always announces as something more useful than "combobox".

<ui-select> Events

| Event | Payload | Description | |-------|---------|-------------| | update:modelValue | any | v-model update | | select | { item, model } | Item selected | | remove | { item, model } | Item removed — also fired by a clear (once per cleared item), so side state keyed to removals stays in sync | | clear | { items, model } | Selection cleared (× icon, Backspace, or the exposed clear() method); items is what was actually cleared, which excludes anything lock-choice protects | | limit-reached | { item, limit } | A pick was refused because limit was already reached — see below | | search | string | Search text changed | | highlight | any | Active option changed (keyboard navigation) | | open | — | Dropdown opened | | close | — | Dropdown closed |

In the select, remove and clear payloads, model is exactly the value v-model has just received, and item / items are in the same shape — so an object (dictionary) source gives you the value, not the internal { key, value } wrapper. Re-selecting something already selected changes nothing and therefore emits nothing.

<ui-select-match> Slots

| Slot | Scope | Description | |------|-------|-------------| | default | { selected, search, isOpen } | Single-mode selected display | | tag | { item, index, remove, removeItem, isLocked } | Multi-mode tag/chip. remove and removeItem are the same function; item is the resolved object even when bind-property is set |

<ui-select-choices> Props

| Prop | Type | Default | Description | |------|------|---------|-------------| | items | any[] \| Record<string, any> | [] | Items source | | track-by | string \| Function | — | Identity key/function | | bind-property | string | — | Emit a nested property instead of full object | | search-fields | string[] | [] | Fields to search. Object items with none declared are NOT filtered (strings/numbers always match by value) | | filter-fn | Function | — | Custom filter function; overrides search-fields | | group-by | string \| Function | — | Group by property or function | | group-filter | string[] \| Function | — | Filter/reorder groups | | disable-choice | Function | — | Predicate: disable specific items | | sort-fn | Function | — | Custom sort function | | minimum-input-length | number | 0 | Min chars before showing results, counted after trimming. The prompt shows from the moment the dropdown opens | | show-checkboxes | boolean | false | Show inline checkboxes in dropdown choices |

<ui-select-choices> Slots

| Slot | Scope | Description | |------|-------|-------------| | choice | { item, index, search, highlighted, isActive, isSelected, isDisabled } | Item row. With an object (dictionary) source the scope also carries key, and item is the unwrapped value | | group-header | { groupName } | Group header | | tag-row | { search, label } | The "(new)" create-tag row shown for a novel term when tagging | | minimum-length | { remaining, minimum } | Localizable "type N more characters" message |

<ui-select-no-choice> Slots

| Slot | Scope | Description | |------|-------|-------------| | default | { reason, search, multiple } | Replaces the built-in empty-state message. reason is 'no-options', 'no-results' or 'all-selected'; search is the trimmed term |

Themes

Import one CSS file:

// Tailwind (default)
import 'vue-ui-select/themes/tailwind.css'

// Bootstrap
import 'vue-ui-select/themes/bootstrap.css'

// Select2
import 'vue-ui-select/themes/select2.css'

// Selectize
import 'vue-ui-select/themes/selectize.css'

Set theme per-instance:

<ui-select theme="bootstrap" ...>

Or globally via plugin:

app.use(UiSelectPlugin, { theme: 'select2' })

Dark mode (Tailwind)

Add .dark to <html> or use [data-theme="dark"]:

<html class="dark">

Form validation

<ui-select> dispatches native input and blur events on its root element, so it integrates with any DOM-event-based validator without coupling. The library knows nothing about specific validation packages — it just behaves like a native form control.

With oop-validator

v-required, v-minlength, v-pattern and other directives drop directly onto <ui-select> the same way ng-required did in AngularJS:

<form name="signup" v-submit="handleSubmit">
  <ui-select
    name="countryId"
    v-model="formData.countryId"
    v-required
  >
    <ui-select-match placeholder="Select a country...">
      <template #default="{ selected }">{{ selected?.name }}</template>
    </ui-select-match>
    <ui-select-choices
      :items="countries"
      track-by="id"
      bind-property="id"
      :search-fields="['name']"
    >
      <template #choice="{ item }">{{ item.name }}</template>
    </ui-select-choices>
  </ui-select>

  <div
    v-show="form.fields.value?.countryId?.$touched || form.$submitted.value"
    v-messages="form.fields.value?.countryId?.$error ?? {}"
  >
    <span v-message="'required'">Please pick a country.</span>
  </div>
</form>
import { useForm } from 'oop-validator/vue'

const formData = reactive({ countryId: null })
const form = useForm('signup', formData)

function handleSubmit() {
  // form.$valid is true here
}

Custom rules for multi-select

oop-validator's built-in v-minlength only works on strings — register a custom rule that implements IValidationRule for "pick at least N":

import { IValidationRule } from 'oop-validator'

class MinSelectionsRule extends IValidationRule {
  private min = 1
  private msg = ''
  constructor(min = 1) { super(); this.min = min }
  isValid(value: unknown): [boolean, string] {
    const arr = value == null ? [] : value
    if (!Array.isArray(arr)) return [false, 'Value must be a list.']
    const ok = arr.length >= this.min
    return [ok, ok ? '' : (this.msg || `Pick at least ${this.min}.`)]
  }
  isMatch(type: string) { return type.toLowerCase() === 'minselections' }
  setParams(params: { length?: number }) {
    if (params?.length != null) this.min = params.length
  }
  setErrorMessage(message: string) { this.msg = message }
}

const rule = new MinSelectionsRule(2)
rule.setErrorMessage('Pick at least 2 languages.')
form.registerRule('languages', 'minSelections', rule)
//                  ↑
//                  rule scoped to this field only — does not bleed to other fields on the same form

See the Form Validation page for a full working demo.

Validation styling

When a validator sets v-invalid AND v-touched on <ui-select> (or on a native <input> / <textarea> / <select> inside your forms), the theme CSS shows a red border. The classes are inert until a validator activates them, so consumers not using validation see no styling difference.

Per-rule classes (v-invalid-required, v-invalid-minlength, etc.) are also set on the host element — useful for targeting specific failure states in custom CSS.

Multi-theme note: the theme CSS files all define rules for native <input> / <textarea> / <select> (using each theme's red palette). If you import more than one theme in the same app, the last-imported theme's red wins on native form controls due to CSS source order. Most projects only need one theme; if you need finer control, import the theme you actually use and override .v-invalid.v-touched colors in your own stylesheet.

Validator-agnostic

Nothing above is specific to oop-validator — the integration is just two native DOM events (input, blur) plus CSS class names (v-invalid, v-touched, etc.). Any validator that observes those events and toggles those classes (VeeValidate with a custom adapter, hand-rolled, etc.) works the same way. vue-ui-select has zero runtime dependency on any validation library.

Migration from AngularJS ui-select

| AngularJS | Vue 3 | |-----------|-------| | <ui-select> | <ui-select> | | <ui-select-match> | <ui-select-match> | | <ui-select-choices> | <ui-select-choices> | | <ui-select-no-choice> | <ui-select-no-choice> | | ng-model | v-model | | repeat="item in items" | :items="items" | | group-by="'prop'" | :group-by="'prop'" | | group-filter | :group-filter | | track by item.id | :track-by="'id'" | | $select.search | search slot prop | | highlight filter | highlighted() slot function | | tagging | :tagging="true" or :tagging="factory" | | tagging-tokens | :tagging-tokens="[',']" | | close-on-select | :close-on-select="false" | | append-to-body | :append-to-body="true" | | theme attribute | theme prop | | refresh="load($select.search)" | @search="load" | | refresh-delay="300" | :debounce="300" on <ui-select> |

Development

# Install dependencies
npm install

# Start playground
npm run dev

# Type check
npm run typecheck

# Run unit tests
npm test

# Run E2E tests
npm run test:e2e

# Build library
npm run build

License

MIT