SearchSelect
ImplementedAsync typeahead search input with debounced server-side querying, optional inline create, keyboard navigation, and ARIA combobox semantics. Generic — takes a fetcher callback, not a hardcoded endpoint.
patterns/molecules/search-select.htmlAsync single-select typeahead with debounced server queries, optional inline create, race-guarded responses, and ARIA combobox semantics.
SearchSelect
Form input molecule — Family 2
A controlled combobox input that calls a debounced async onSearch fetcher as the user types and renders results in an absolutely-positioned listbox. Optionally offers an inline-create row (onCreate) when the query matches nothing. Single-select only; focus never leaves the input.
This component supports light and dark mode.
Usage
SearchSelect is a controlled async typeahead: pass a fetcher (onSearch), hold the selection in state (value + onChange), and always provide ariaLabel — it is required at the type level. The component owns debouncing (300 ms by default), a request race guard that drops stale responses, outside-click dismissal, and keyboard navigation. Wire it to your real API inside the fetcher — the component is endpoint-agnostic.
import { useState } from 'react'
import { SearchSelect } from '@epasslive/design-system'
const [venue, setVenue] = useState(null)
<SearchSelect
ariaLabel="Venue"
placeholder="Search venues…"
value={venue}
onChange={setVenue}
onSearch={(q) => fetchVenues(q)}
resultLimit={3}
refineLabel="Keep typing to refine"
/>Inline create
Provide onCreate and a create row appears whenever the search returns no results. Selecting it (click or Enter) calls your creator with the trimmed query and adopts the returned option as the selection. The row label defaults to the literal text Add "query" built from the trimmed query — pass createLabel to customize it, as in the second live example above. While the create request is in flight the row shows loadingLabel.
<SearchSelect
ariaLabel="Artist"
value={artist}
onChange={setArtist}
onSearch={(q) => searchArtists(q)}
onCreate={(q) => createArtist(q)}
createLabel={(q) => `Add new artist "${q}"`}
noResultsLabel="No artist found"
/>SearchSelect vs Select vs Listbox
Use SearchSelect for async, server-backed single-select typeahead with optional inline create. Use Select for static or locally searched option lists, multi-select, and button-triggered popovers. Use Listbox for an always-visible inline selection list with no text input. Do not use SearchSelect for static option lists — Select is the correct static widget.
Accessibility
The input exposes role="combobox" with aria-expanded, aria-controls, aria-autocomplete="list", and the required ariaLabel. The popup is a role="listbox" whose rows are role="option". Keyboard: ArrowDown opens, ArrowUp/Down navigate (including the create row), Home/End jump, Enter selects or creates, Escape closes and restores the selected label, Tab closes. Focus stays in the input throughout.
Known gap (tracked for the next cascade step): the input does not yet set aria-activedescendant and option rows carry no ids, so arrow-key navigation is silent to screen readers; aria-selected also currently tracks the visually active row rather than the chosen option. The active-option tint (teal at 10% with theme-swapping text tokens) passes WCAG AA 4.5:1 in both light and dark themes.
Props
placeholderstringSearch…Placeholder text shown when the input is empty.
value*SearchSelectOption | nullControlled selected option. Pass null to clear.
onChange*(option: SearchSelectOption | null) => voidFired on select or create; fired with null whenever the query is edited (selection clears while typing).
onSearch*(query: string) => Promise<SearchSelectOption[]>Async fetcher called with the debounced, trimmed query. Stale responses are dropped by a request race guard.
onCreate(query: string) => Promise<SearchSelectOption>When provided, an inline-create row appears when search returns no results.
createLabel(query: string) => stringLabel builder for the create row. Falls back to the literal Add "query" text when omitted.
noResultsLabelstringNo resultsText shown when the search finished with no results and onCreate is not provided.
loadingLabelstringLoading…Text shown while a search or create request is in flight.
refineLabelstringMuted hint row appended when the result count reaches resultLimit.
resultLimitnumber10Maximum number of results displayed; also gates the refineLabel hint.
debounceMsnumber300Debounce delay in milliseconds before onSearch fires.
disabledbooleanfalseDisables the whole control. Per-option disabling is not supported.
requiredbooleanfalseMarks the field required (aria-required on the input).
idstringInput id; auto-generated with React.useId() when omitted. The listbox id derives from it.
classNamestringAdditional CSS classes appended to the wrapper div.
ariaLabel*stringAccessible name for the combobox input and listbox popup.
Props
SearchSelect
onSearch(query: string) => Promise<SearchSelectOption[]>—Async fetcher called with the debounced query string
valueSearchSelectOption | nullnullCurrently selected option
onChange(option: SearchSelectOption | null) => void—Callback fired when the user selects an option or clears the input
onCreate(query: string) => Promise<SearchSelectOption>undefinedWhen provided, an inline-create affordance appears when search returns no results
debounceMsnumber300Debounce delay before firing the search request
resultLimitnumber10Maximum results to display
placeholderstring'Search…'Placeholder text shown when the value is empty.
createLabel(query: string) => string`Add "${query}"`Builds the inline-create row label from the trimmed query. Falls back to `Add "${query}"` when omitted.
noResultsLabelstring'No results'Empty-state text shown when the query returns no results and no onCreate is provided.
loadingLabelstring'Loading…'Text shown while results are fetching, and while an inline create is in progress.
refineLabelstring—Hint shown when the result count hits resultLimit, prompting a more specific query.
disabledbooleanfalseDisables the control.
requiredbooleanfalseMarks the field as required.
ariaLabelstring—Accessible name applied to both the input and the results listbox.
idstringuseId()Stable id for the input; the listbox id is derived as `${id}-listbox`.
classNamestring—Class appended to the relative-positioned root container.
<SearchSelect />Accessibility
ARIA
1 rulea11y
1 ruleUsage
Import
import { SearchSelect } from "@epasslive/design-system"Example 1
<SearchSelect onSearch={searchVenues} value={venue} onChange={setVenue} ariaLabel="Venue" />Example 2
<SearchSelect onSearch={searchArtists} value={artist} onChange={setArtist} onCreate={createArtist} ariaLabel="Artist" />Anti-patterns
— Do not use for static option lists — use Select instead
— Do not add a static-options variant here — a native <select> (Select atom) is the correct static widget