Components · Forms

Search

A search input that opens an autocomplete listbox while the user types. Pick a known entity from a list — a station, a customer, a terminal, a setting — without leaving the keyboard.

Documentedby Derek Fidler

Use the full station name as it appears on Dutch Railways.

Type a station name. Arrow keys move the highlight, Enter commits, Escape clears.

Overview.

Search is a combobox: an input that owns its own listbox of suggestions. The user types, the parent narrows the list, the component renders it. Picking a row commits the suggestion throughonSelect and closes the menu. The component never decides what counts as a match — that's the parent's job.

Search doesn't filter

The component renders whatever suggestions you pass. Sort, dedupe, fuzzy-match, hit your API, debounce — all in the parent. That keeps Search agnostic about ranking and lets the same component back a station picker, a settings finder, and a command palette with a single API.

When to use Search.

Reach for Searchwhen there's a finite list to narrow against and the outcome is selecting one of its entries. Suggestions appear inline, the user moves with arrow keys, Enter commits. Examples: station picker, customer lookup, setting finder, command palette.

For free-text queries that submit to a results page or filter a list elsewhere on the surface — no inline suggestions — use a plain TextField with type="search" and showClearButton. Don't reach for Search just because the verb is the same.

Sizes.

Two heights. Match the search to its surroundings: prominent on empty surfaces, compact in toolbars.

size="md"

60 px tall. The default for page-level finders, modal-anchored pickers, and any prominent search.

size="sm"

40 px tall. For toolbars, table headers, side rails — anywhere the search shares a row with other compact controls.

States.

Idle, filled, error, disabled, loading. The hairline ring thickens to 2 px on focus without shifting the input's content — focus state is a ring, not a width change.

Idle

Filled

Use the full station name as it appears on Dutch Railways.

With helper

No station with that spelling. Try “Amsterdam”.

Error

Disabled

Loading

Grouped suggestions.

Pass groups when the listbox mixes sources. Each group gets a quiet eyebrow heading; arrow keys still traverse a single flattened list, so the user never has to think about the grouping.

Group suggestions when the menu mixes sources — e.g. recent picks alongside new matches.

As a command palette.

Same combobox, different content. Add a leading icon for each row's domain, a trailing kbd for the shortcut, and you get a finder without inventing a new component.

Same combobox, dressed for a finder. Leading icons read each row's domain; trailing keys document the shortcut.

Asynchronous queries.

For server-backed searches, debounce the query and pass loading while the request is in flight. The spinner takes the search icon's slot so the input doesn't reflow while results stream in.

Debounce queries with a 200–400 ms timer and pass loading through. The spinner reuses the search-icon slot so the input doesn't reflow.

Empty state.

When the query has no matches, the listbox shows a single quiet line. Default copy is “No matches.”; override emptyMessageto add a recovery action — “Add ‘Amsterdaam’ as a new alias” or a link to fuller search.

When the query has no matches, the listbox shows a single quiet line. Override emptyMessage to add a recovery action.

Anatomy.

Three parts on the input row itself; the listbox and helper line sit on either side of it. The listbox is the headline element — every other piece exists to serve typing into it.

Helper text sits here.

  1. Leading icon

    Magnifier by default; swaps to a spinner while loading. Override via the icon prop for non-search variants (a keyboard glyph for command palettes, for instance).

  2. Input

    Free-text query. Owns role combobox and the focus ring. Spelling, autocorrect, and capitalisation are off — search inputs almost never want them.

  3. Clear button

    Round × that wipes the value and refocuses the input. Hidden when the input is empty so an idle field stays calm.

Behavior.

  • The menu opens on focus + content. The listbox appears whenever the input is focused AND there's something to show — either non-empty suggestions or a non-empty query (so the empty-state line can render). An empty input focused into a fresh page won't flash an empty menu.
  • Selecting doesn't mutate the input. onSelect hands the suggestion to the parent; the parent decides whether to write its label back into the field, navigate away, or do something else entirely. This keeps the component honest about who owns state.
  • Highlight resets to the first enabled option. Every time suggestions change, the highlight jumps back to the top. The user can press Enter immediately — no second ArrowDown — and they get the most-likely match.
  • Escape clears in two stages. Esc with the menu open closes the menu but leaves the value in place. Esc again — menu closed, value present — clears the field. Native browser behaviour, just made explicit.
  • Mouse hover and keyboard highlight are the same state. Moving the mouse over an option moves the highlight; pressing ArrowDown moves it too. There's no separate “mouse focus” rectangle, so the eye and the cursor never disagree.

Accessibility.

Search implements the WAI-ARIA 1.2 combobox pattern. role="combobox" sits on the input itself, not the wrapper, with aria-expanded and aria-controls wired to the listbox below. Keyboard focus stays in the input; aria-activedescendant points the screen reader at the highlighted option without moving DOM focus.

  • ↑ ↓Move the highlight; opens the menu if closed.
  • Home / EndJump to the first or last enabled suggestion.
  • EnterCommit the highlighted suggestion. No-op when nothing is highlighted.
  • EscClose the menu, then clear the input on a second press.
  • TabClose the menu and move focus to the next control. Never hijacked.

Code.

Two patterns. The first is fully synchronous — pre-loaded data filtered in the browser. The second debounces against a remote endpoint with an in-flight token to avoid stale results.

Synchronous

tsx

import { Search, type SearchSuggestion } from "@flatpay-dk/ui";

const stations: SearchSuggestion[] = [
  { id: "ams-cs", label: "Amsterdam Centraal", description: "Stationsplein, Amsterdam" },
  { id: "ut-cs",  label: "Utrecht Centraal",   description: "Stationshal, Utrecht" },
  // …
];

function StationPicker() {
  const [query, setQuery] = React.useState("");
  const [picked, setPicked] = React.useState<SearchSuggestion | null>(null);

  const matches = stations.filter((s) =>
    s.label.toLowerCase().includes(query.trim().toLowerCase()),
  );

  return (
    <Search
      placeholder="Search stations"
      value={query}
      onValueChange={setQuery}
      suggestions={matches}
      onSelect={(s) => {
        setPicked(s);
        setQuery(s.label);
      }}
    />
  );
}

Debounced async

tsx

import { Search } from "@flatpay-dk/ui";

function CustomerSearch() {
  const [query, setQuery] = React.useState("");
  const [results, setResults] = React.useState<SearchSuggestion[]>([]);
  const [loading, setLoading] = React.useState(false);
  const tokenRef = React.useRef(0);

  React.useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }
    setLoading(true);
    const token = ++tokenRef.current;
    const handle = window.setTimeout(async () => {
      const next = await api.searchCustomers(query);
      // Drop stale responses.
      if (token !== tokenRef.current) return;
      setResults(next);
      setLoading(false);
    }, 300);
    return () => window.clearTimeout(handle);
  }, [query]);

  return (
    <Search
      placeholder="Find a customer"
      value={query}
      onValueChange={setQuery}
      suggestions={results}
      loading={loading}
      onSelect={(s) => router.push(`/customers/${s.id}`)}
      emptyMessage={
        <>
          No customer matches &ldquo;{query}&rdquo;.{" "}
          <a href="/customers/new" className="underline">
            Create one
          </a>
          .
        </>
      }
    />
  );
}

Best practices.

Do

Use Search when the user is picking one of N. The combobox shape is what makes the operation legible.

Wrong tool — no listbox needed

Don't

Don't reach for Search to filter a visible list — use a TextField with type="search" and filter in place.

stations.filter(s =>
  s.label.toLowerCase()
    .includes(query.toLowerCase())
)

Do

Pre-filter on the parent. Trim, lowercase, fuzzy-match, sort — Search renders, the parent ranks.

Cap results at ~10.

Don't

Don't pass thousands of unfiltered rows. The listbox virtualises nothing; cap results at 8–12 to keep it scannable.

Props.

PropTypeDefaultDescription
valuestringControlled input value. Pair with onValueChange.
defaultValuestring""Uncontrolled initial value.
onValueChange(next: string) => voidFires on every keystroke. Treat this as the search query.
suggestionsSearchSuggestion[] | SearchSuggestionGroup[]Already-filtered suggestions. Pass groups for multi-section menus.
onSelect(s: SearchSuggestion) => voidFires when a suggestion is committed via click or Enter.
loadingbooleanfalseSwaps the leading magnifier for a spinner. Doesn't disable input.
size"sm" | "md""md"sm = 40 px (toolbars, table headers); md = 60 px (page-level finders).
showClearButtonbooleantrueWhether the × clear button renders when the field has a value.
helpTextReactNodeHelper line below the input. Hidden when error is set.
errorReactNodeError message; replaces the helper line and adds the rose ring.
emptyMessageReactNode"No matches."Rendered inside the listbox when the query has no matches.
iconReactNodeOverride the default magnifier — useful for command palettes.
open / onOpenChangeboolean / (open: boolean) => voidFully control the listbox open state. Skip for default focus-driven behaviour.
onClear() => voidFires when the user clears via × or Escape on a non-empty value.