nori-ui

Select

Searchable, async-capable, optionally virtualized select with custom item renderers, RTL, locale-aware sort, and OptGroup support.

7.1 kBgzipped

At a glance

Two operating modes:

  • Static — pass options. The picker filters in-memory by substring match on label (override via filterOption).
  • Async — pass loadOptions(params). Called on search-input change (debounced ~150 ms) and when the list scrolls near the bottom for the next page. The component manages the loaded list and pagination cursor itself, so consumers don't have to.

Other capabilities:

  • Searchable — auto-on for static lists ≥ 10 items and always on for async; override with searchable.
  • Virtualized list — auto-on for lists > 100 items; only the visible window of options is rendered.
  • OptGroup support — items with the same group field cluster under a group header.
  • Locale-aware sort — pass locale="de" (or any BCP 47) and the picker re-sorts via Intl.Collator. Switch the locale and the list re-orders alphabetically for the new language.
  • Custom item renderer — pass renderOption for arbitrary content per row.
  • RTL — pass dir="rtl"; popup alignment + text direction flip.
  • Keyboard nav — Arrow Down/Up move highlight, Enter selects, Escape closes, Tab closes.

Static — with groups

Items with the same group field cluster under a header. Searchable auto-on once the list reaches 10 items.

Direction:
Locale:

Multi-select

Pass multiple to switch the value / onChange types to arrays. The trigger renders a chip per selected option (collapsing to "N selected" past maxChips, default 3); the popup shows a checkbox-style indicator next to each option, stays open between picks, and exposes a "Clear all" affordance plus aria-multiselectable="true". Optional maxSelected caps the count — extra picks are silently ignored.

Direction:
Locale:

Capped selection

Cap the number of selectable items with maxSelected — extra picks are ignored silently. Pair with a smaller maxChips so the trigger stays compact while the cap is approached. The example below caps at 3 and collapses to "N selected" past 2 chips.

Direction:
Locale:

Async + paginated

loadOptions is called on every search change (debounced ~150ms) and on scroll near the bottom of the list. The component manages the loaded array and pagination cursor; consumers only return { items, total? }.

Direction:
Locale:

Virtualized

Pass a thousand items in. Only the visible window is rendered (auto-on once the option count crosses 100).

Direction:
Locale:

Custom item renderer

renderOption(option, { selected, active }) returns the JSX for each row. The picker still owns selection state, keyboard nav, and group headers — the custom renderer only owns the row's content.

Direction:
Locale:

Locale-aware sort

locale="de" (or any BCP 47) re-sorts the list via Intl.Collator. Switch the locale and the order updates so the alphabet matches the language a reader is in. Pair with sortByLocale={false} to keep your preferred order while still benefiting from a locale-tagged accessibility tree.

Direction:
Locale:

Use with Field

For labelled selects with description, error, and a11y wiring, wrap in <Field>:

import { Field, Select } from '@nori-ui/core';
 
export const Example = () => (
    <Field label="Country" description="Where we'll ship your order.">
        <Select placeholder="Pick a country" options={[
            { value: 'us', label: 'United States' },
            { value: 'de', label: 'Germany' },
            { value: 'jp', label: 'Japan' },
        ]} />
    </Field>
);

With validation error:

import { Field, Select } from '@nori-ui/core';
 
export const Example = () => (
    <Field label="Country" error="Please select a country.">
        <Select placeholder="Pick a country" options={[
            { value: 'us', label: 'United States' },
            { value: 'de', label: 'Germany' },
        ]} />
    </Field>
);

See Field for the full API. For custom layouts (e.g., a control + button row), use the compound API.

value and defaultValue

Controlled via value (paired with onChange); uncontrolled via defaultValue. Single-mode types are string; multi-mode (multiple) types are string[]. Mixing the two prefers controlled.

// Controlled single
<Select value={planId} onChange={setPlanId} options={…} />
 
// Uncontrolled multi
<Select multiple defaultValue={['a', 'b']} options={…} />

placeholder, searchPlaceholder, noOptionsMessage, loadingMessage

Each of the user-visible labels is overridable. Use them to localize or to write copy that matches your product voice.

PropDefaultWhere it shows
placeholder"Select…"Trigger when nothing is selected
searchPlaceholder"Search…"Search input when searchable is on
noOptionsMessage"No options"Popup body when the filtered list is empty
loadingMessage"Loading…"Popup body while async results are pending
<Select
    placeholder="Pick a country"
    searchPlaceholder="Type to filter…"
    noOptionsMessage="No matches"
    options={…}
/>

disabled

Greys the trigger, blocks pointer + keyboard interaction, and forwards aria-disabled. Individual options can also be disabled via disabled: true on the option.

<Select disabled defaultValue="locked" options={[{ value: 'locked', label: 'Locked' }]} />

dir

Right-to-left layout. Flips the popup alignment, text direction, and icon sides so the field reads naturally in Arabic / Hebrew / RTL locales.

<Select dir="rtl" options={…} />

aria-label

Accessibility label for the trigger when there's no visible label elsewhere. Forwarded to the combobox role.

<Select aria-label="Country" options={…} />

Performance — virtualized, itemHeight, maxMenuHeight, pageSize

Knobs for tuning the popup with large datasets:

  • virtualized — render only the visible window of options. Auto-on for lists > 100 items; pass false to force off, true to force on.
  • itemHeight — fixed pixel height per row. Default 36. Required input for the virtualization math; bump it if you render taller custom rows via renderOption.
  • maxMenuHeight — popup height in px before it scrolls. Default 320.
  • pageSize — async-only; how many items to fetch per loadOptions call. Default 50.
<Select
    virtualized
    itemHeight={48}
    maxMenuHeight={400}
    pageSize={100}
    loadOptions={fetchPage}
/>

sortByLocale

When locale is set the picker auto-sorts via Intl.Collator. Pass sortByLocale={false} to keep your incoming option order while still exposing the locale to assistive tech and to per-row formatting.

<Select locale="de" sortByLocale={false} options={…} />

Field integration and accessibility props

Wrap Select in <Field> for a label, description, and error message with automatic ARIA wiring. The following attributes are injected onto the trigger automatically by Field.Control:

  • id — matches the htmlFor of the label generated by Field.
  • aria-labelledby — references the Field.Label element's ID.
  • aria-describedby — references Field.Description and/or Field.Error when present.
  • aria-invalid — set when the enclosing Field has a non-null error.
  • aria-required — set when Field has required={true}.

You can also pass any of these props explicitly when using Select without a Field wrapper.

Props

PropTypeDefaultDescription
aria-describedbystring
aria-invalidboolean
aria-labelstring
aria-labelledbystring
aria-requiredboolean
classNamestring
defaultValuestring | readonly string[]Uncontrolled initial value. Uncontrolled initial values.
direnumRTL flips the popup alignment + text direction.
disabledbooleanDisable interaction.
filterOption(option: SelectOption<T>, search: string) => booleanOverride the default substring filter for static options.
idstring
itemHeightnumberPixel height of a single item — required for virtualization math. @defaultValue 36
loadingMessagestringMessage shown while async results are loading.
loadOptions(params: LoadOptionsParams) => Promise<LoadOptionsResult<T>>Async loader. Called with `{ search, offset, limit }` whenever the search input changes (debounced) or the user scrolls near the end of the loaded list. Return more items + an optional total to stop the pagination loop early.
localestringBCP 47 locale — drives `Intl.Collator` sorting of options when set. Re-sorts on language switch so a German list reads alphabetically in German vs the same list in English.
maxChipsnumberMax chips to render in the trigger before collapsing to "N selected". @defaultValue 3
maxMenuHeightnumberMax popup height in px. @defaultValue 320
maxSelectednumberHard cap on selected count — extra picks are ignored.
multiplebooleanfalseSingle-select mode (default — omit or pass `false`). Multi-select mode — value/onChange become array-typed.
namestring
noOptionsMessagestringMessage shown in the popup when there are no matching options.
onChange((value: string, option: SelectOption<T>) => void) | ((values: readonly string[], options: readonly SelectOption<T>[]) => void)Fires when the user picks an option. Fires when the selection changes. Receives the full new array of values + their resolved options.
optionsreadonly SelectOption<T>[]Static options. Mutually exclusive with `loadOptions`.
pageSizenumberPage size for `loadOptions`. @defaultValue 50
placeholderstringTrigger placeholder when no value is selected.
renderOption(option: SelectOption<T>, info: SelectRenderOptionInfo) => ReactNodeCustom item renderer. Called per option in the list.
searchablebooleanShow a search input above the list. @defaultValue auto-on for static options >= 10 items, always on for loadOptions
searchPlaceholderstringPlaceholder for the search input.
sortByLocalebooleanWhen `locale` is set, sort options alphabetically. @defaultValue true
testIDstring
valuestring | readonly string[]Controlled value. Controlled values.
virtualizedbooleanVirtualize the list — only DOM-render the visible window of items. Auto-on when the list has more than 100 items.
Preview theme