Select Dropdown

Free

Single, multi-select, grouped and searchable dropdown with rich option rendering.

</>sv-select-dropdown selectdropdownform
import { SvSelectDropdownComponent } from '@styloviz/select-dropdown';

Demo & usage

Basic usage

Usage example — open the gallery for the full interactive demo.

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Grouped options

Pass groups for sectioned, labelled lists.

example.htmlAngular
<sv-select-dropdown
  [groups]="frameworkGroups"
  placeholder="Pick a stack"
  [(value)]="stack"
  (selectionChange)="onChange($event)" />

Compact, filled

example.htmlAngular
<sv-select-dropdown [options]="options" size="sm" variant="filled" [(value)]="value" />

Searchable with remote filtering

searchChange fires on every query — debounce it to fetch matches from an API. Customise the empty/search copy.

example.htmlAngular
<sv-select-dropdown
  [options]="results"
  [searchable]="true"
  [clearable]="true"
  searchPlaceholder="Search users…"
  noResultsText="No users match"
  (searchChange)="onSearch($event)"
  [(value)]="userId" />

Server search, without wiring it yourself

Hand it the endpoint instead of the results. The debounce, the request guard, the abort, the loading/stale split and the retry come with it — the guard being the part everyone skips, and skipping it is what makes a picker show suggestions for the term before last.

example.htmlAngular
<sv-select-dropdown
  [loadOptions]="searchPeople"
  [selectedOption]="current()"
  [searchable]="true"
  [minChars]="2"
  [(value)]="personId" />

<!-- searchPeople = (term: string, signal: AbortSignal) =>
       fetch(`/api/people?q=${encodeURIComponent(term)}`, { signal })
         .then((r) => r.json());

     Pass selectedOption too: a fetched list is whatever answers the
     current term, so a perfectly valid selection is routinely not in
     it — and without the hydration input the trigger calls it stale. -->

Composed: features as children, options from an adapter

The new shape (FI-23-M1). Features are content children that render nothing and register through a token; where the options come from is one adapter, svSelectSource from @styloviz/core, which owns search, paging and the loading / stale / error states. The legacy inputs still work and are the fallback: each setting resolves as source, then feature child, then the select's own input.

example.htmlAngular
<sv-select-dropdown [source]="people" [selectedOption]="current()" [(value)]="personId">
  <ng-container svSelectSearch searchPlaceholder="Search people…" />
</sv-select-dropdown>

<!-- Pro adds windowing and bulk selection the same way: -->
<sv-select-dropdown-pro [options]="rows()" [multiple]="true" [maxSelected]="10" [(value)]="ids">
  <ng-container svSelectSearch />
  <ng-container svSelectVirtual [optionHeight]="52" />
  <ng-container svSelectBulk selectAllLabel="Add everyone" />
</sv-select-dropdown-pro>

<!-- people = svSelectSource<SelectOption>({
       search: (term, signal, { offset, limit }) => api.people(term, offset, limit, signal),
       pageSize: 50,   // rows per "Load more"; 0 = whole answer per term
       minChars: 2,
     }); -->

When to use

  • Choosing one (or a few) options from a known, moderate-length list.
  • You want search, grouping or rich option rendering beyond a native <select>.
  • For free-text entry with suggestions use Autocomplete; for many tags use Tag Input.

Async states

  • loading
  • error
  • retry
  • partial

Inputs

NameTypeDefaultDescription
valuestring | string[] (two-way)''Selected value(s) — string for single, string[] for multiple (two-way).
optionsreadonly SelectOption[][]Flat list of options. Use `groups` instead for sectioned lists.
groupsSelectGroup[][]Grouped options with section labels.
placeholderstring'Select an option'Placeholder shown when nothing is selected.
labelstring''Field label.
hintstring''Helper text shown below the field.
errorMessagestring''Error message; puts the field in an error state when set.
serverErrorstring | { readonly message: string } | nullnullA validation failure reported by the **server** — the 422 that comes back after submit, not something the client could have known. It is a separate input from `errorMessage` because it has a different lifetime, not because it needs a second way to show a string. A server error is a statement about *the value that was submitted*: the moment the user edits the field it stops being true, and this input expires itself rather than sitting there telling someone their new email is already taken. `errorMessage` has no such expiry — it is the host's to clear. While live it outranks `errorMessage`: it is the more specific fact, and it is the one the user has not seen yet. Typed structurally rather than as `SvDataError` so this stays dependency free — an `SvDataError` satisfies it unchanged.
sizeSelectSize'md'Field size: sm, md or lg.
variantSelectVariant'default'Visual variant.
multiplebooleanfalseAllow selecting multiple options.
searchablebooleanfalseShow a search box to filter options.
searchPlaceholderstring'Search…'Placeholder for the search box.
loadingTextstring'Loading…'Panel copy while the first set of options is being fetched.
noResultsTextstring'No results found'Text shown when a search yields no matches.
clearablebooleanfalseShow a clear (×) control.
disabledbooleanfalseDisable the field.
loadingbooleanfalseShow a loading state.
stalebooleanfalseThe results on screen answer an **older** query — a newer request is still in flight. Distinct from `loading`, which cannot separate the two cases it covers: the first fetch (nothing on screen — show a skeleton) and a refinement of results already visible (keep them, and say they are provisional). Blanking the panel on the second is the most jarring thing a picker can do; showing the old list with no indication is a quiet lie. Bind `svSuggestController(...).stale` from `@styloviz/data`, or `createRequestGuard().stale` from `@styloviz/core`. The results dim and report `aria-busy` while staying fully interactive.
requiredbooleanfalseMark the field as required.
optionalTagbooleanfalseShow an "optional" tag next to the label.
maxSelectednumber0Max selectable options in multiple mode (0 = unlimited).
inputIdstring''Explicit id for the control (for external <label for>).
ariaLabelstring''Accessible name for the trigger when there is no visible `label`. Use this instead of a native `aria-label` on the host element (which is not a labelable role and would be an ARIA violation). Falls back to the `placeholder` so the combobox always has an accessible name.
customClassstring''Extra CSS classes for the control.
serverSidebooleanfalseLet the server do the searching. The supplied `options` are shown exactly as given — filtering them again hides entries the server matched on fields the component cannot see, and client-side search can only ever look at the page already downloaded.
debounceMsnumber300Delay before `searchChange` fires, in milliseconds. `0` emits immediately.
minCharsnumber0Characters required before a search is sent. Below this the panel says so rather than showing a misleading "No results found".
errorSvDataError | string | nullnullFailure loading the options. Rendered inside the panel, so the trigger and the current selection stay put.
hasMorebooleanfalseMore options exist on the server. Renders a "Load more" row.
loadingMorebooleanfalseTrue while the next page is being fetched.
selectedOptionSelectOption | nullnullThe option matching the current `value`, for when its label lives on the server and is not in `options` yet. Without it a picker restored from a saved id shows the raw id until the user opens the panel.
loadOptionsSvSelectOptionSource | nullnullLet the component fetch its own options. MEASURED — what `serverSide` actually left the host holding. The six remote inputs above describe a request; none of them *makes* one. A host wiring this picker to `/api/people` had to write, by hand, in every component that uses it: a subscription to `(searchChange)`, a request-guard so a slow answer to an abandoned term cannot overwrite a newer one, an `AbortController` so the abandoned request stops costing the server, the `loading`/`stale` split so a refinement dims the list instead of blanking it, a try/catch mapping the failure onto `error`, and a `(retry)` handler re-running the last term. Six inputs and three outputs of ceremony for one URL. The guard is the part everyone skips, and skipping it looks correct in review. Hand it a function instead: ```html <sv-select-dropdown [loadOptions]="searchPeople" [selectedOption]="current()" [searchable]="true" [minChars]="2" /> ``` ```ts searchPeople = (term: string, signal: AbortSignal) => fetch(`/api/people?q=${encodeURIComponent(term)}`, { signal }) .then((r) => r.json()); ``` With a source set, `options`, `loading`, `stale` and `error` are driven by the component and the host's own values for them are OR-ed in rather than replaced — a host may still be loading the *selected* entity while the panel is idle. `serverSide` is implied: a list this component just fetched must not then be re-filtered against the same term, which would hide the entries the server matched on fields the component cannot see. ⚠️ **Pass `selectedOption` too.** The fetched list is whatever answers the current term, so a perfectly valid selection is routinely *not in it* — and without the hydration input the trigger would call it stale and read "Unavailable". The component cannot tell "not in these results" from "deleted on the server", and guessing the friendly answer would undo Dev mode warns once when a value is held and neither the results nor `selectedOption` resolve it. ⚠️ **Not compatible with `hasMore` / `loadMore`.** A source returns the whole answer for a term; there is nowhere for the component to append a page it did not ask for. Paging stays host-driven through `options`. Dev mode warns once if both are used.
sourceSvSelectSource<SelectOption> | nullnullWhere the options come from — search, paging and async state as one object. Build it with `svSelectSource({ search, pageSize, minChars, debounceMs })` from `@styloviz/core`. It replaces `loadOptions`, `serverSide`, `hasMore`, `loadingMore`, `loading`, `stale`, `error`, `(retry)` and `(loadMore)`: with a source set the component fetches on open, searches as the user types, appends pages on "Load more" and retries a failure itself. The legacy inputs are still OR-ed in, and the legacy outputs still fire. Takes precedence over `loadOptions` when both are set.

Outputs

EventPayload typeDescription
selectionChangestring | string[]Emitted with the new selection (string or string[]).
dropdownOpenvoidEmitted when the dropdown opens.
dropdownClosevoidEmitted when the dropdown closes.
searchChangestringEmitted with the search query whenever it changes (for remote filtering).
retryvoidThe host should re-run the failed option load.
loadMorevoidThe user reached the end of the list and wants the next page.
valueChangestring | string[]Emits the new `value` whenever it changes — the output half of `[(value)]`.

<sv-select-dropdown-pro>

Also in this family — SvSelectDropdownProComponent from @styloviz-pro/select-dropdown.

Async states

  • loading
  • error
  • retry
  • partial

Inputs

NameTypeDefaultDescription
valuestring | string[] (two-way)''Selected value(s) — string for single, string[] for multiple (two-way).
optionsreadonly SelectOption[][]Flat list of options. Use `groups` instead for sectioned lists.
groupsSelectGroup[][]Grouped options with section labels.
placeholderstring'Select an option'Placeholder shown when nothing is selected.
labelstring''Field label.
hintstring''Helper text shown below the field.
errorMessagestring''Error message; puts the field in an error state when set.
serverErrorstring | { readonly message: string } | nullnullA validation failure reported by the **server** — the 422 that comes back after submit, not something the client could have known. It is a separate input from `errorMessage` because it has a different lifetime, not because it needs a second way to show a string. A server error is a statement about *the value that was submitted*: the moment the user edits the field it stops being true, and this input expires itself rather than sitting there telling someone their new email is already taken. `errorMessage` has no such expiry — it is the host's to clear. While live it outranks `errorMessage`: it is the more specific fact, and it is the one the user has not seen yet. Typed structurally rather than as `SvDataError` so this stays dependency free — an `SvDataError` satisfies it unchanged.
sizeSelectSize'md'Field size: sm, md or lg.
variantSelectVariant'default'Visual variant.
multiplebooleanfalseAllow selecting multiple options.
searchablebooleanfalseShow a search box to filter options.
searchPlaceholderstring'Search…'Placeholder for the search box.
loadingTextstring'Loading…'Panel copy while the first set of options is being fetched.
noResultsTextstring'No results found'Text shown when a search yields no matches.
clearablebooleanfalseShow a clear (×) control.
disabledbooleanfalseDisable the field.
loadingbooleanfalseShow a loading state.
stalebooleanfalseThe results on screen answer an **older** query — a newer request is still in flight. Distinct from `loading`, which cannot separate the two cases it covers: the first fetch (nothing on screen — show a skeleton) and a refinement of results already visible (keep them, and say they are provisional). Blanking the panel on the second is the most jarring thing a picker can do; showing the old list with no indication is a quiet lie. Bind `svSuggestController(...).stale` from `@styloviz/data`, or `createRequestGuard().stale` from `@styloviz/core`. The results dim and report `aria-busy` while staying fully interactive.
requiredbooleanfalseMark the field as required.
optionalTagbooleanfalseShow an "optional" tag next to the label.
maxSelectednumber0Max selectable options in multiple mode (0 = unlimited).
virtualThresholdnumber150Render only the visible window once the FLAT option list is longer than this. `0` turns windowing off entirely. A THRESHOLD, NOT A BOOLEAN, BECAUSE WINDOWING IS NOT FREE. It costs a height estimate that can be wrong, spacer rows that confuse anything walking the DOM, and an option that may not be mounted when something asks for it. Below a couple of hundred rows none of that buys anything a browser was not already doing well, so the default only switches it on where it pays for itself. Grouped lists are never windowed — see `virtualizeEnabled`.
optionHeightnumber0Row height in pixels for the virtual window. `0` derives it from `size`, and from whether any option carries a `sublabel`. THE ESTIMATE IS THE SCROLLBAR. Windowing replaces the un-rendered rows with a spacer of `count × height`, so an estimate that is off by 20 % makes a scrollbar that is off by 20 % — the thumb reaches the bottom before the list does, or stops short of it. The derived default is right for the rows this component draws; set this if you have styled them.
virtualViewportHeightnumber0Height of the scrolling option list in pixels, for the virtual window. `0` derives it from the panel height measured when the dropdown opens.
showSelectAllbooleanfalseShow a tri-state "select all" control above the list, in `multiple` mode. IT ACTS ON THE VISIBLE, ENABLED OPTIONS — see `select-bulk.ts`. Not on the whole array: with a filter on, that would select the rows the user just filtered out, and with `serverSide` paging it cannot mean anything else anyway. The count is in the label so the scope is stated rather than assumed.
selectAllLabelstring'Select all'Wording for the bulk control. The count is appended.
inputIdstring''Explicit id for the control (for external <label for>).
ariaLabelstring''Accessible name for the trigger when there is no visible `label`. Use this instead of a native `aria-label` on the host element (which is not a labelable role and would be an ARIA violation). Falls back to the `placeholder` so the combobox always has an accessible name. Mirrors the free `sv-select-dropdown` input deliberately: an upgrade free→Pro must not drop an accessibility API and fail with NG8002.
customClassstring''Extra CSS classes for the control.
serverSidebooleanfalseLet the server do the searching. The supplied `options` are shown exactly as given — filtering them again hides entries the server matched on fields the component cannot see, and client-side search can only ever look at the page already downloaded.
debounceMsnumber300Delay before `searchChange` fires, in milliseconds. `0` emits immediately.
minCharsnumber0Characters required before a search is sent. Below this the panel says so rather than showing a misleading "No results found".
errorSvDataError | string | nullnullFailure loading the options. Rendered inside the panel, so the trigger and the current selection stay put.
hasMorebooleanfalseMore options exist on the server. Renders a "Load more" row.
loadingMorebooleanfalseTrue while the next page is being fetched.
selectedOptionSelectOption | nullnullThe option matching the current `value`, for when its label lives on the server and is not in `options` yet. Without it a picker restored from a saved id shows the raw id until the user opens the panel.
loadOptionsSvSelectOptionSource | nullnullLet the component fetch its own options. MEASURED — what `serverSide` actually left the host holding. The six remote inputs above describe a request; none of them *makes* one. A host wiring this picker to `/api/people` had to write, by hand, in every component that uses it: a subscription to `(searchChange)`, a request-guard so a slow answer to an abandoned term cannot overwrite a newer one, an `AbortController` so the abandoned request stops costing the server, the `loading`/`stale` split so a refinement dims the list instead of blanking it, a try/catch mapping the failure onto `error`, and a `(retry)` handler re-running the last term. Six inputs and three outputs of ceremony for one URL. The guard is the part everyone skips, and skipping it looks correct in review. Hand it a function instead: ```html <sv-select-dropdown [loadOptions]="searchPeople" [selectedOption]="current()" [searchable]="true" [minChars]="2" /> ``` ```ts searchPeople = (term: string, signal: AbortSignal) => fetch(`/api/people?q=${encodeURIComponent(term)}`, { signal }) .then((r) => r.json()); ``` With a source set, `options`, `loading`, `stale` and `error` are driven by the component and the host's own values for them are OR-ed in rather than replaced — a host may still be loading the *selected* entity while the panel is idle. `serverSide` is implied: a list this component just fetched must not then be re-filtered against the same term, which would hide the entries the server matched on fields the component cannot see. ⚠️ **Pass `selectedOption` too.** The fetched list is whatever answers the current term, so a perfectly valid selection is routinely *not in it* — and without the hydration input the trigger would call it stale and read "Unavailable". The component cannot tell "not in these results" from "deleted on the server", and guessing the friendly answer would undo Dev mode warns once when a value is held and neither the results nor `selectedOption` resolve it. ⚠️ **Not compatible with `hasMore` / `loadMore`.** A source returns the whole answer for a term; there is nowhere for the component to append a page it did not ask for. Paging stays host-driven through `options`. Dev mode warns once if both are used.
sourceSvSelectSource<SelectOption> | nullnullWhere the options come from — search, paging and async state as one object. Build it with `svSelectSource({ search, pageSize, minChars, debounceMs })` from `@styloviz/core`. It replaces `loadOptions`, `serverSide`, `hasMore`, `loadingMore`, `loading`, `stale`, `error`, `(retry)` and `(loadMore)`: with a source set the component fetches on open, searches as the user types, appends pages on "Load more" and retries a failure itself. The legacy inputs are still OR-ed in, and the legacy outputs still fire. Takes precedence over `loadOptions` when both are set.

Outputs

EventPayload typeDescription
selectionChangestring | string[]Emitted with the new selection (string or string[]).
dropdownOpenvoidEmitted when the dropdown opens.
dropdownClosevoidEmitted when the dropdown closes.
searchChangestringEmitted with the search query whenever it changes (for remote filtering).
retryvoidThe host should re-run the failed option load.
loadMorevoidThe user reached the end of the list and wants the next page.
valueChangestring | string[]Emits the new `value` whenever it changes — the output half of `[(value)]`.

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.

Template

Bound to the names this component actually exposes.

usage.htmlAngular
<sv-select-dropdown
  [serverSide]="true"
  [options]="data.items()"
  [loading]="data.loading()"
  [error]="data.error()"
  [hasMore]="data.hasMore()"
  (retry)="data.reload()"
/>

Host class

Drive the three async inputs from your own fetch.

host.component.tsTypeScript
// This component reports its state but does not emit a query, so drive
// it from your own fetch and bind the three async inputs.
readonly items = signal<Row[]>([]);
readonly loading = signal(false);
readonly error = signal<SvDataError | null>(null);

async load(): Promise<void> {
  this.loading.set(true);
  this.error.set(null);
  try {
    this.items.set(await this.api.fetchRows());
  } catch (e) {
    this.error.set(toSvDataError(e));
  } finally {
    this.loading.set(false);
  }
}
  • This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `options`, `loading` and `error`.
  • `serverSide` switches **off** the built-in filtering, sorting and paging. Leave it false and the component works entirely from the array you pass — which is what you want for a few hundred rows.
  • `debounceMs` (default 300) collapses a burst of typing into one request. Set it to 0 only when the query is driven by clicks.
  • `hasMore` and `loadMore` drive incremental paging; bind `hasMore` from the controller's own signal of the same name.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • Pass `options` (and `groups`) for grouped lists; each option supports `icon` (an icon-library name), `avatarUrl`, `sublabel`, `badge` / `badgeColor` and `disabled`.
  • Toggle `multiple`, `searchable`, `clearable`; cap multi-select with `maxSelected` and size/style via `size` and `variant` (default · filled · flushed · floating).
  • Pro: long flat lists window themselves above `virtualThreshold` (150), with `optionHeight` / `virtualViewportHeight` to override the derived sizes. Grouped lists are never windowed — headers and options are two different index spaces, and half-doing it would navigate wrongly.
  • Pro: `showSelectAll` adds a tri-state bulk control to a multi-select. It acts on the VISIBLE, ENABLED options — not the whole array — deselects only what is on screen, and refuses (rather than filling up to) a `maxSelected` cap.
  • Customise copy with `searchPlaceholder` and `noResultsText`; drive validation with `required`, `errorMessage` and `hint`.
  • React to `selectionChange`, `dropdownOpen` / `dropdownClose`, and `searchChange` (for remote/async filtering).
  • Composed shape (`FI-23-M1`): `svSelectSearch` (searchable, searchPlaceholder, noResultsText, loadingText, minChars, debounceMs, serverSide; `(searchChange)`), Pro `svSelectVirtual` (virtualThreshold, optionHeight, virtualViewportHeight) and Pro `svSelectBulk` (showSelectAll, selectAllLabel) are content children; an unbound member falls through to the select's own input. `[source]` takes `svSelectSource({ search, pageSize, minChars, debounceMs })` from `@styloviz/core` and outranks `loadOptions`. `maxSelected` stays on the select — it constrains the value. Free ships only `svSelectSearch`; the Pro directives share the same token and are ignored by the free select.
  • `loadOptions` lets the component own the fetch: it drives `options`, `loading`, `stale` and `error` itself and implies `serverSide`, because a list it just fetched must not be re-filtered against the same term — that would hide the rows the server matched on fields the component cannot see. The host's own `loading` / `error` are OR-ed in rather than replaced, so you can still report a failure of your own. Opening fetches; closing aborts what is in flight but keeps what is on screen, so a live selection does not read as "Unavailable" the moment the panel shuts. `loadingText` is the copy for the first load.

Best practices

  • Do: Enable search once the list passes ~8 options so users can type to filter.
  • Do: Group long lists with `groups` and concise section labels.
  • Don't: Avoid using a dropdown for 2–3 mutually exclusive choices — a Segmented Control or Radio Group is clearer.
  • Tip: Provide a sensible `placeholder` describing the choice, not just "Select…".
  • Do: Decide what happens when a selected value leaves the option list. The control no longer renders the PLACEHOLDER for it — that said "Select an option" while the form still held a value, so a user would read the field as empty and submit something they believed they never chose. It now reads `Unavailable (<id>)`, and `hasStaleValue()` / `staleValues()` let you react.
  • Don't: Don't confuse a stale value with a hydrating one. `selectedOption` supplies the label for a value restored from a server before its list has loaded — that is "not yet", and it is not stale. "Not any more" is a value the list has actively dropped.
  • Do: Prefer `loadOptions` over wiring `(searchChange)` yourself once the list comes from a server. The hand-rolled version needs a request guard so a slow answer to an abandoned term cannot overwrite a newer one, and that is the piece that gets left out — it looks correct in review and only misbehaves under latency you do not have locally.
  • Don't: Don't combine `loadOptions` with `hasMore` / `loadMore`. A source returns the whole answer for a term, so there is nowhere for the component to append a page it did not ask for — keep paging host-driven through `options`. Dev mode warns if both are set.
  • Do: Prefer the composed shape for new code: `[source]="svSelectSource({ search, pageSize })"` pages where `loadOptions` cannot, and `svSelectSearch` / `svSelectVirtual` / `svSelectBulk` keep each feature's settings together. The twenty-one inputs and outputs they replace are `@deprecated` (the JSDoc names each replacement) and keep working until the next major — deliberately with no console warning.

Accessibility

  • Full ARIA combobox: `role="combobox"` with `aria-expanded`, `aria-haspopup`, `aria-controls` and `aria-activedescendant`; the popup is a `role="listbox"` of `role="option"` rows with stable ids.
  • Keyboard: arrows, Home/End to move, Enter to select, Esc/Tab to close — the active option is scrolled into view and announced to screen readers.
  • Pro, windowed lists: the active option is always kept mounted. `aria-activedescendant` names it by DOM id, and a window that unmounted it would leave that id dangling — the screen reader then announces nothing at all as the user arrows. The spacer rows are `aria-hidden` so a windowed listbox does not report two unreachable phantom items.
  • A unique id is generated automatically so the `<label>` association, `aria-labelledby` and `aria-describedby` (hint/error) always resolve, even without `inputId`.
  • Closes on outside click and returns focus to the trigger; programmatic `formControl.disable()` disables the native trigger.

Feedback on Select Dropdown

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.