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

@aiquants/select-box

v0.10.0

Published

High-performance select box component for React with virtual scrolling and fuzzy search

Readme

@aiquants/select-box

A high-performance React select box component with virtual scrolling, fuzzy search, multi-selection, and advanced multi-directional cross-script text normalization.

Features

  • Virtual Scrolling: Built with @aiquants/virtualscroll to handle 1,000+ options smoothly
  • Fuzzy Search: Built with @aiquants/fuzzy-search using Web Workers for non-blocking search
  • Multi-Directional & Cross-Script Normalization: Supports full-width ↔ half-width, Hiragana ↔ Katakana, Romaji ↔ Kana, case-insensitive, and Japanese variant cross-matching out of the box
  • Fail-Safe Match Classification: exact and substring matches are classified directly against every option, never through the fuzzy worker — a fuzzy-engine failure or debounce delay can only affect the fuzzy bucket, not deterministic matches
  • Single & Multi Select: Unified API for both modes via the multiple prop
  • Clearable: Optional clear button in both single- and multi-select mode; clearing emits null (single) or [] (multiple)
  • Resizable Dropdown: Drag the bottom-right handle to resize (bounds configurable); the resized height survives closing and reopening
  • Search Highlighting & Score Gradient: Highlights exact matches (emerald green) and fuzzy matches (amber gradient scaled by similarity score)
  • Search/Display Decoupling: searchText / searchFields search decorated labels correctly; renderOption takes over row rendering
  • Themeable & Dark Mode: Every color routes through --sb-* CSS variables; dark palette keys off the .dark class on <html>
  • Density & Row Height: density="compact" preset or an explicit optionHeight for dense screens
  • Worker Isolation: workerId gives each instance its own fuzzy-search index (required when multiple SelectBoxes share a screen)
  • Full Keyboard Navigation: Arrow / Page / Home / End / Enter / Escape / Alt+Arrow, with scroll-follow that works at 1,500+ options — DOM focus never leaves the input
  • IME-Safe: Every key handler stands down while a composition is in flight, so Japanese conversion (candidate window, Enter to confirm) is never intercepted
  • WAI-ARIA Combobox: role="combobox" + aria-expanded / aria-controls / aria-activedescendant, a named role="listbox" popup, per-row ids and aria-posinset / aria-setsize that report the FULL set size rather than the rendered virtual window, plus a polite live region for the result count
  • Disabled Options: option.disabled renders aria-disabled, ignores clicks and is skipped by every navigation key
  • Localizable: built-in English ("en", default) and Japanese ("ja") through locale; every built-in string (button names, empty message, live-region and selection texts, default placeholder, the resize-handle tooltip, the popup's scroll chrome) is overridable through labels — see Localization
  • TypeScript: Complete type definitions included

Installation

pnpm add @aiquants/select-box

Peer Dependencies

@aiquants/fuzzy-search and @aiquants/virtualscroll are mandatory — SelectBox renders its list through virtualscroll and filters through fuzzy-search, so neither is optional. Install all four:

pnpm add @aiquants/fuzzy-search @aiquants/virtualscroll react react-dom

Inside this monorepo both @aiquants/* peers are declared as workspace:^ and resolve to the local packages, so a root pnpm install is all you need.

select-box 0.10 requires @aiquants/virtualscroll 3.7.0 or later — every host, whether or not it passes locale / labels. The package imports the engine's label catalog and resolvers when the module loads, so on an older engine the import itself fails (ESM: missing named export; CJS: a TypeError at load) and no SelectBox renders at all.

Quick Start

import { useState, useMemo } from "react"
import { SelectBox, type Option } from "@aiquants/select-box"
// Required: Vite environments need explicit Worker URLs
import indexWorkerUrl from "@aiquants/fuzzy-search/worker/indexWorker?url"
import levenshteinWorkerUrl from "@aiquants/fuzzy-search/worker/levenshteinWorker?url"

const options: Option[] = [
  { label: "Apple", value: "apple" },
  { label: "Banana", value: "banana" },
  { label: "Cherry", value: "cherry" },
]

function SingleSelect() {
  const [value, setValue] = useState<Option | null>(null)
  const workerUrls = useMemo(() => ({
    indexWorker: indexWorkerUrl,
    levenshteinWorker: levenshteinWorkerUrl,
  }), [])

  return (
    <SelectBox
      options={options}
      value={value}
      onChange={(val) => setValue(val as Option | null)}
      placeholder="Select a fruit..."
      workerUrls={workerUrls}
    />
  )
}

function MultiSelect() {
  const [values, setValues] = useState<Option[]>([])
  const workerUrls = useMemo(() => ({
    indexWorker: indexWorkerUrl,
    levenshteinWorker: levenshteinWorkerUrl,
  }), [])

  return (
    <SelectBox
      options={options}
      value={values}
      onChange={(val) => setValues(Array.isArray(val) ? val : [])}
      multiple
      placeholder="Select multiple..."
      workerUrls={workerUrls}
    />
  )
}

Multi-Directional & Cross-Script Normalization

@aiquants/select-box matches queries against option data even when character scripts or width styles differ:

// Querying "さくら" (Hiragana) matches "サクラ" (Katakana) & "サクラ" (Half-width Katakana)
// Querying "sakura" (Romaji) matches "サクラ" (Katakana) & "桜"
// Querying "10" (Full-width digits) matches "10" (Half-width digits)
<SelectBox
  options={[
    { label: "10 | サクラ", value: "1" },
    { label: "20 | サクラ", value: "2" },
  ]}
  normalizeOptions={{
    convertKana: true,   // Hiragana ↔ Katakana cross-matching
    convertWidth: true,  // Full-width ↔ Half-width normalization (NFKC)
    convertRomaji: true, // Romaji ↔ Kana transliteration
  }}
/>

CSS Setup

The package ships two CSS artifacts; pick one per host. SelectBox builds on @aiquants/virtualscroll (and @aiquants/fuzzy-search), so the virtualscroll stylesheet is a peer CSS dependency — import it once alongside select-box.

  • Tailwind v4 host — import the components-only build in layer(components), do the same for the virtualscroll peer build, and let your Tailwind build emit the JSX utilities from the package source:

    /* app tailwind.css */
    @import "@aiquants/virtualscroll/styles/virtualscroll.css" layer(components);
    @import "@aiquants/select-box/styles/select-box.css" layer(components);
    @source "../node_modules/@aiquants/select-box/src/**/*.{ts,tsx}";
    /* monorepo: @source "../../../../packages/select-box/src/**/*.{ts,tsx}"; */

    select-box.css (Artifact A) carries only the hand-written .sb-* classes (its @apply rules resolved to var(--token, fallback) references) — no :root theme variables, no Tailwind utilities, no preflight — and it does not embed the virtualscroll stylesheet. Do not also @import the standalone build here: the duplicated utilities flip the base/variant cascade order.

  • Non-Tailwind host — import the single self-contained standalone build; it bundles the .sb-* classes, every JSX utility, and the virtualscroll peer CSS:

    @import "@aiquants/select-box/styles/select-box.standalone.css";

    Dark styles key off the .dark class on <html>.

Migrating to 0.7.0

0.7.0 turns the component into a real WAI-ARIA combobox. Three observable changes:

  1. The input's role is now combobox, not textbox. Update test locators: getByRole("textbox") → getByRole("combobox") (Testing Library, Playwright).
  2. Option rows are no longer focusable. They lost tabIndex={0} and their Enter/Space handler, because the APG excludes a popup and its descendants from the tab sequence. Keyboard selection now happens on the input (ArrowDown … Enter), which is what the rows never reachably provided. Tests that tabbed into a row must drive the input instead.
  3. Focusing the input no longer opens the popup. The APG is explicit that moving focus to a combobox does not display its popup, and focus-driven opening re-entered badly (clearing the selection hands focus back to the input, which reopened the popup it had just closed). The popup now opens on pointer press, on typing, on ArrowDown / ArrowUp / Alt+ArrowDown, and on the chevron — i.e. every path a user actually takes. Only autoFocus on mount behaves differently.

Everything else is additive. Give each instance an ariaLabel while you are there — see Accessibility notes.

Localization

SelectBox renders eight strings of its own plus the scroll chrome of the dropdown's VirtualScroll. All of them come from a frozen per-locale catalog, SELECT_BOX_LABEL_CATALOGS, in English ("en", the default) and Japanese ("ja"):

| Key | Where it appears | en | ja | | --- | --- | --- | --- | | clear | clear button (aria-label) | Clear selection | 選択を解除 | | toggle | chevron button (aria-label) | Toggle options | 候補を開閉 | | removeTag | chip remove button (aria-label), (option) => string | Remove <label> | 「<label>」を削除 | | noOptions | visible empty message of the open popup | No options found | 一致する候補がありません | | status | polite live region, (info: SelectBoxStatusInfo) => string | "" while closed or searching; No options found; 1 option available; <n> options available | "" while closed or searching; 一致する候補がありません; <n> 件の候補があります | | selection | selection description (aria-describedby), (selected: Option[]) => string | "" when none; Selected: <label>; <n> selected | "" when none; 選択中: <label>; <n> 件を選択中 | | placeholder | input placeholder when the placeholder prop is omitted | Select... | 選択... | | resizeHandle | SVG <title> of the popup's resize-handle glyph — the handle is aria-hidden, but browsers show the title as a native tooltip on hover | Resize handle | サイズ変更ハンドル | | scrollUp / scrollDown | disabled arrow buttons of the popup's vertical scrollbar (aria-label) | Scroll up / Scroll down | 上へスクロール / 下へスクロール | | scrollLeft / scrollRight / scrollToTop / scrollToBottom / noItems | never rendered by SelectBox (accepted for a uniform vocabulary) | from @aiquants/virtualscroll | from @aiquants/virtualscroll |

The seven engine keys (scrollUp … noItems) are VIRTUAL_SCROLL_LABEL_CATALOGS[locale] of @aiquants/virtualscroll, reused by value — one source of truth for the scroll chrome.

Pick the language with locale and override single keys with labels:

<SelectBox
  options={options}
  locale="ja"
  labels={{ noOptions: '該当なし', removeTag: (option) => `「${option.label}」を外す` }}
/>
  • Omitting locale renders English; the DOM is byte-identical to locale="en".
  • locale and labels are resolved at the top of every render, so a bad value fails at mount, not on the first popup open. SelectBox forwards the resolved locale and the seven engine keys to the dropdown's VirtualScroll (value-stable across renders, so an inline labels={{ … }} does not make the engine re-resolve).
  • Fail-fast (RangeError): an unsupported locale ("EN", "ja-JP", "fr", "", null, ...); labels that is not a plain object (its prototype must be Object.prototype or null — arrays, class instances and Object.create(proto) objects are rejected); an unknown key; a present clear / toggle / noOptions / placeholder / resizeHandle or engine key that is not a string with non-whitespace content; a present removeTag / status / selection that is not a function. An undefined value keeps the catalog value. Only own properties are read. Messages describe the rejected value without stringifying it (got "ja-JP", got null, got number).
  • status and selection may legitimately return "": an empty status keeps the live region silent, an empty selection drops aria-describedby.
  • The placeholder prop is per-instance content and wins over the placeholder label.
  • No language negotiation: the package never reads navigator.language. Map it yourself to a supported value (the list is VIRTUAL_SCROLL_LOCALES of @aiquants/virtualscroll), and set the document language (<html lang>) yourself — no lang attribute is stamped.
  • locale is not a formatting locale. It selects the UI chrome language only. A BCP 47 tag such as "ja-JP" (what Intl APIs take, sometimes called formatLocale elsewhere) is a different axis and is rejected here.

Exported API: SELECT_BOX_LABEL_KEYS (the 15 keys: the 7 engine keys, then clear, toggle, removeTag, noOptions, status, selection, placeholder, resizeHandle), SELECT_BOX_LABEL_CATALOGS (the frozen catalogs), resolveSelectBoxLabels(locale, labels) (the effective frozen labels — the catalog object itself when labels is undefined), and the types SelectBoxLocale, SelectBoxLabels and SelectBoxLabelOverrides.

Migrating from 0.9 to 0.10

Breaking changes

  1. texts is replaced by locale + labels (clean cut-over, no alias). The texts prop, the SelectBoxTexts type and the DEFAULT_SELECT_BOX_TEXTS value are gone. labels keeps the same member names and signatures (clear, toggle, removeTag(option), noOptions, status(info), selection(selected)) and adds placeholder, resizeHandle and the seven scroll-chrome keys of @aiquants/virtualscroll.
  2. labels is validated at mount (RangeError). In 0.9, texts filled each member with ??: a null member or a misspelled key fell back to English silently, a "" / whitespace string was rendered as-is (an empty accessible name or a blank empty message), and a non-function removeTag / status / selection threw a TypeError only when it was called during render. All of these now throw a RangeError at mount that names the key: a non-plain object, an unknown key, null / "" / whitespace for a string key, or a non-function for a function key. Omit a member (or pass undefined) to keep the catalog value.
  3. @aiquants/virtualscroll 3.7.0 or later is required by the whole package, not only by hosts that use locale / labels (see Peer Dependencies). Upgrade the engine together with select-box.
  4. Hosts that pinned ^0.9.x must bump to ^0.10.0 explicitly (a 0.x minor is outside the caret range).

New API: the locale and labels props, SELECT_BOX_LABEL_KEYS, SELECT_BOX_LABEL_CATALOGS, resolveSelectBoxLabels, and the types SelectBoxLocale, SelectBoxLabels, SelectBoxLabelOverrides (all described above).

Steps

// 0.9
<SelectBox texts={{ noOptions: '該当なし', status: myStatus }} />
// 0.10
<SelectBox locale="ja" labels={{ noOptions: '該当なし', status: myStatus }} />
  • Japanese UI: opt in with locale="ja". The default chrome language is English (unchanged from 0.9), so a host that rewrote strings in Japanese through texts should pass locale="ja" and keep only the overrides whose wording differs from the ja catalog. locale="ja" also translates the strings texts could never reach: the default placeholder, the resize-handle tooltip and the popup's scroll-chrome button names.
  • Rename texts to labels and drop members that were null / "" / whitespace (see item 2).
  • Code that spread DEFAULT_SELECT_BOX_TEXTS reads SELECT_BOX_LABEL_CATALOGS.en (frozen) instead.
  • locale is not formatLocale. Pass "en" / "ja", never a BCP 47 tag such as "ja-JP"; select-box has no formatting locale (packages that do call it formatLocale, a separate axis).
  • Hosts that omit locale see a DOM byte-identical to 0.9.2 (the en catalog reuses the previous English literals exactly).

API Reference

SelectBox Props

| Prop | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | options | Option[] | Yes | - | Array of selectable options | | value | Option \| Option[] \| null | No | undefined | Current selection (controlled); null round-trips from onChange | | onChange | (value: Option \| Option[] \| null) => void | No | undefined | Selection change callback (null on single-select clear) | | placeholder | string | No | the placeholder label of locale ("Select..." / "選択...") | Per-instance placeholder shown when nothing is selected; takes precedence over the catalog default (an explicit "" is kept). Only this prop — never the catalog default — names the listbox when ariaLabel is absent | | multiple | boolean | No | false | Enable multi-select mode | | className | string | No | "" | Additional CSS class for the container | | disabled | boolean | No | false | Disable the component | | clearable | boolean | No | false | Show a clear button whenever at least one value is selected and the component is not disabled; clicking emits null (single) or [] (multiple), resets the query and closes the popup | | autoFocus | boolean | No | false | Auto-focus the input on mount | | enableHighlight | boolean | No | true | Enable/disable search result highlighting and score gradients | | normalizeOptions | NormalizeOptions | No | DEFAULT_NORMALIZE_OPTIONS (exported) | Multi-directional text normalization settings (case, width, kana, romaji, variants) | | workerUrls | { indexWorker?: string; levenshteinWorker?: string } | No | undefined | Explicit Web Worker URLs (see below) | | workerId | string | No | undefined | Isolation key for the fuzzy-search worker/index. Give every SelectBox on the same screen a distinct value — instances sharing the default worker evict each other's index on every dataset switch | | searchText | (option: Option) => string | No | undefined | Derives the search-target string per option, decoupling search from label decoration (padding, separators, units). Unset searches label | | searchFields | string[] | No | undefined | Additional option fields to search besides the base target | | threshold | number | No | 0.3 | Fuzzy-match similarity threshold (0.0-1.0). Only affects the fuzzy bucket — exact and substring matches are classified directly and never filtered by it | | sortBy | "score" \| "relevance" | No | "score" | Scoring mode of the fuzzy engine for the fuzzy bucket; "relevance" adds matched-field-count weighting | | renderOption | (option, ctx: { query; score; matchType }) => ReactNode | No | undefined | Custom row renderer; replaces the default label/highlight rendering entirely | | showMatchScore | boolean | No | false | Show each row's match category and score as a subtle badge (search-tuning aid); only rendered while a query is active | | optionHeight | number | No | 36 (28 compact) | Dropdown row height in px, applied to both the virtual-scroll math and the row elements | | density | "comfortable" \| "compact" | No | "comfortable" | Display density preset; compact shrinks rows to 28px and text to text-xs | | id | string | No | undefined (the input gets no id; generated ids fall back to an sb-<useId> prefix) | DOM id of the input, applied only when supplied. Every generated id — <base>-listbox, <base>-option-<index>, <base>-selection — uses <base> = this prop when given, else sb-<useId>. Supply it for deterministic ids in tests and to wire a <label htmlFor> | | name | string | No | undefined | Form field name. The selection is submitted through hidden inputs — one per selected value under multiple, a single empty one when nothing is selected. The search field deliberately carries no name, so a submit never sends the raw query (or an empty string after a single-select commit) | | ariaLabel | string | No | undefined | Accessible name applied to both the combobox and its listbox popup. ARIA requires each to be named independently — a <label> attached to the input does not name the popup | | ariaLabelledBy | string | No | undefined | Same as ariaLabel, by id reference; takes precedence | | autoHighlight | boolean | No | false | Activate the best match while typing so Enter accepts it. Default false follows the APG (typing returns visual focus to the textbox) and keeps Enter available for form submission | | loopNavigation | boolean | No | true | Wrap Arrow navigation at the list ends | | dropdownHeight | number | No | 300 | Initial popup height in px. A drag-resize now survives closing and reopening | | minDropdownHeight | number | No | 100 | Lower bound of the resize drag | | maxDropdownHeight | number | No | 600 | Upper bound of the resize drag | | locale | SelectBoxLocale ("en" \| "ja") | No | "en" | UI chrome language of every built-in string, including the scroll chrome of the popup's VirtualScroll. An unsupported value throws a RangeError at mount. See Localization | | labels | SelectBoxLabelOverrides | No | undefined | Per-key overrides laid over the catalog of locale. Validated at mount (RangeError on an unknown key, a blank string, or a non-function for removeTag / status / selection). See Localization | | onActiveIndexChange | (index: number, option: Option \| null) => void | No | undefined | Fires whenever the keyboard-highlighted row changes (-1 when nothing is highlighted) | | onOpenChange | (isOpen: boolean) => void | No | undefined | Fires whenever the popup opens or closes |

Keyboard

DOM focus stays on the text input at all times; the highlighted row is published through aria-activedescendant (the WAI-ARIA APG "combobox with list autocomplete" pattern). Every handler stands down while an IME composition is in flight.

| Key | Popup closed | Popup open | | --- | --- | --- | | ArrowDown | Opens, highlighting the current selection if it is in the list | Next option (wraps unless loopNavigation={false}) | | ArrowUp | Opens, highlighting the current selection | Previous option (wraps) | | Alt+ArrowDown | Opens without highlighting anything | – | | Alt+ArrowUp | – | Closes | | PageDown / PageUp | (browser default) | ± one viewport of rows, clamped at the ends | | Home / End | (caret) | First / last option when the query is empty; otherwise the native caret move is preserved (APG's editable-combobox behavior) | | Ctrl/Cmd+Home / End | (browser default) | First / last option, regardless of the query | | Enter | (browser default — form submit still works) | Commits the highlighted row; with nothing highlighted the key is not intercepted | | Escape | Not intercepted, so a parent dialog still receives it | Closes without committing | | Tab | – | Closes without committing, focus moves on | | Backspace | Removes the last chip when multiple and the query is empty | Same — the branch is not gated on the popup state |

Wrap-around follows the APG reference example; the pattern text permits either behavior. PageDown / PageUp are a documented extension — the APG lists paging keys only for grid popups. Escape carries no query-clearing branch because it cannot be reached: every path that closes the popup also clears the query, so isOpen === false && query !== "" is not a host-reachable state.

Mouse wheel

When the list is taller than the popup, a vertical wheel over it stays inside the popup even at the edges: once the list has reached its last (or first) row, further notches are consumed instead of being chained to the page, so an open, scrollable popup stays under its input on a page that overflows vertically. This is the virtualscroll overscrollBehavior: "contain" policy, fixed by the package (virtualscroll >= 2.7.0 defaults to chaining). It applies only to the list's own scroll area: a list that fits inside the popup (8 rows or fewer at the default 36px rows / 300px popup, 10 at density="compact"), the empty "no options" popup and the resize-handle corner are not scroll targets, so a wheel there reaches the page. Horizontal and Shift+wheel gestures are never intercepted. A host-side .sb-dropdown { overscroll-behavior: contain } rule is broader than this policy: Chromium cuts its wheel scroll chain at any scroll container whose overscroll-behavior is not auto — an overflow: hidden box included — so there such a rule also stops the fitting-list, empty-popup and resize-corner cases. This package does not test that rule and other engines are not verified; keep such a rule if you rely on those cases.

Accessibility notes

  • Give every instance an ariaLabel (or ariaLabelledBy). ARIA requires an accessible name on the combobox and on the listbox, and the popup is not covered by a label attached to the input.
  • Option rows are deliberately not focusable and carry no tabIndex: the APG excludes a popup and its descendants from the page tab sequence. The chevron is tabIndex={-1} for the same reason.
  • aria-posinset / aria-setsize carry the absolute position and the full option count, not the rendered virtual window, so a screen reader announces "item 900 of 1,552" correctly.
  • The role="listbox" sits on the virtual scroller's content element (via @aiquants/virtualscroll's contentProps), not on a wrapper around it. That element owns exactly the rendered rows; the custom scrollbar and the resize handle are its siblings, so the listbox owns nothing but options. The declared peer range of @aiquants/virtualscroll in package.json already guarantees this.

Option Type

interface Option {
  label: string           // Display text (default search target; override with searchText/searchFields)
  value: string | number  // Unique identifier
  disabled?: boolean      // Non-selectable: aria-disabled, ignores clicks, skipped by navigation keys
  [key: string]: any      // Custom metadata
}

While a query is active the engine rebuilds every option, attaching the internal fields _searchText, _normalizedSearchText, _score (match score) and _matchType ("exact" | "partial" | "fuzzy" ranking category — the non-"none" members of the exported MatchCategory). All four are engine-internal and never reach onChange — useSelectBox's toEmittableOption strips them before emitting, so the value your handler receives always carries exactly the keys you supplied, whether or not the user typed.

One consequence: when a query was active, the emitted object is a new object rather than the one you passed in (it is your own object only when nothing was typed). Compare selections by value, never by reference.

Rows still publish their score and category to renderers through renderOption's second argument (RenderOptionContext), which is the supported seam. Read matchType there rather than decoding score: the 0.9 partial sentinel can numerically collide with genuine fuzzy scores.

Decorated Labels (code + name)

Searching a decorated label like "10 | 桜" breaks: the | token matches every option, while a name-only query can miss entirely. Decouple display from search instead:

<SelectBox
  options={options} // { label, value, code, name }
  searchText={(o) => `${o.code} ${o.name}`}
  renderOption={(o) => (
    <span style={{ display: "grid", gridTemplateColumns: "6ch 1fr", gap: "0.5rem" }}>
      <span>{o.code}</span>
      <span style={{ borderLeft: "1px solid var(--sb-border)", paddingLeft: "0.5rem" }}>{o.name}</span>
    </span>
  )}
/>

Theming

Every color and the component font size resolve through --sb-* CSS custom properties declared on .sb-container, defaulting to the light palette (each default chains to the host's Tailwind token, e.g. var(--color-gray-300, oklch(…))). Override any subset on a wrapper or on .sb-container itself:

.my-app .sb-container {
  --sb-bg: #101828;
  --sb-fg: #f3f4f6;
  --sb-border: #364153;
  --sb-font-size: 0.75rem;
}

Key variables: --sb-bg, --sb-fg, --sb-border, --sb-border-hover, --sb-focus, --sb-placeholder, --sb-icon, --sb-icon-hover, --sb-disabled-bg, --sb-tag-bg, --sb-tag-border, --sb-option-hover-bg, --sb-option-focused-bg, --sb-option-selected-bg, --sb-option-selected-fg, --sb-highlight-exact-bg, --sb-highlight-exact-fg, --sb-highlight-fuzzy-bg, --sb-highlight-fuzzy-fg, --sb-font-size, --sb-line-height (full inventory in src/styles/select-box.css).

Dark mode ships as a .dark .sb-container variable block in both CSS artifacts, keyed off the .dark class on <html>. There is deliberately no @media (prefers-color-scheme) rule — it would auto-darken light-only apps for OS-dark users; hosts wanting OS-driven switching map the variables themselves.

Web Worker URLs (Important)

The fuzzy search uses Web Workers internally. In Vite development mode, the default import.meta.url-based Worker resolution may fail, causing errors like:

[fuzzy-search] Index Worker Error
[fuzzy-search] Index Worker: Ping timeout
[fuzzy-search] Failed to rebuild index: Error: Index build timeout

To fix this, pass workerUrls with Vite's ?url import:

import indexWorkerUrl from "@aiquants/fuzzy-search/worker/indexWorker?url"
import levenshteinWorkerUrl from "@aiquants/fuzzy-search/worker/levenshteinWorker?url"

<SelectBox
  options={options}
  workerUrls={{
    indexWorker: indexWorkerUrl,
    levenshteinWorker: levenshteinWorkerUrl,
  }}
/>

Dependencies

| Package | Purpose | | --- | --- | | @aiquants/fuzzy-search | Web Worker-based fuzzy search engine | | @aiquants/virtualscroll | Virtual scrolling for large lists |

Documentation

See the docs/specs/ directory for detailed specifications.

License

MIT