Skip to content
design
Components

Search input

Search a list or choose a matching result, with clear feedback and keyboard interaction.

On this page
Try wind, 244 or PCWY. Use the arrow keys and Enter, or tap a result.Type a name, MMSI or call sign to find a vessel.

Details appear here when you choose a vessel.

Find an example vessel by name, MMSI or call sign, then choose it to see its details.
Code
import { SearchSuggestions, type SearchSuggestion } from "@bosunkit/components/search-input";
import { Box } from "@bosunkit/components/box";
import { Stack } from "@bosunkit/components/stack";
import { Text } from "@bosunkit/components/text";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";

interface Vessel extends SearchSuggestion {
  mmsi: string;
  callSign: string;
}

const VESSELS: readonly Vessel[] = [
  { value: "windgrazer", label: "Windgrazer", mmsi: "244123456", callSign: "PCWY" },
  { value: "althea", label: "Althea", mmsi: "244234567", callSign: "PDAL" },
  { value: "northwind", label: "North Wind", mmsi: "235098765", callSign: "MNTW7" },
  { value: "sea-lark", label: "Sea Lark", mmsi: "227123456", callSign: "FSLK" },
];

const MATCH_STYLE: CSSProperties = { background: "var(--accent-dim)", color: "inherit", fontWeight: "bold" };

function highlight(text: string, query: string): ReactElement {
  const start: number = text.toLowerCase().indexOf(query);
  return start < 0 || query === "" ? (
    <>{text}</>
  ) : (
    <>
      {text.slice(0, start)}
      <mark style={MATCH_STYLE}>{text.slice(start, start + query.length)}</mark>
      {text.slice(start + query.length)}
    </>
  );
}

export const description: string =
  "Search the example vessels by name, MMSI or call sign. Try wind, 244 or PCWY, then choose a vessel to see its details.";

export default function Find(): ReactElement {
  const [query, setQuery]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
  const [selected, setSelected]: [Vessel | null, Dispatch<SetStateAction<Vessel | null>>] = useState<Vessel | null>(
    null,
  );
  const wanted: string = query.trim().toLowerCase();
  const shown: readonly Vessel[] =
    wanted === ""
      ? []
      : VESSELS.filter((vessel: Vessel): boolean =>
          [vessel.label, vessel.mmsi, vessel.callSign].some((text: string): boolean =>
            text.toLowerCase().includes(wanted),
          ),
        );
  const status: string =
    wanted === ""
      ? "Type a name, MMSI or call sign to find a vessel."
      : shown.length === 0
        ? "No vessels match. Try fewer letters or check the identifier."
        : `${String(shown.length)} ${shown.length === 1 ? "vessel matches" : "vessels match"}. Choose one to see its details.`;
  return (
    <Stack space="md">
      <SearchSuggestions
        label="Find a vessel"
        placeholder="Name, MMSI or call sign"
        hint="Try wind, 244 or PCWY. Use the arrow keys and Enter, or tap a result."
        value={query}
        onValueChange={setQuery}
        items={shown}
        onSelect={(item: SearchSuggestion): void => {
          setSelected(VESSELS.find((vessel: Vessel): boolean => vessel.value === item.value) ?? null);
        }}
        status={status}
        clearLabel="Clear the vessel search"
        renderItem={(item: SearchSuggestion): ReactElement => {
          const vessel: Vessel | undefined = VESSELS.find((entry: Vessel): boolean => entry.value === item.value);
          return (
            <span>
              {highlight(item.label, wanted)}
              <br />
              <Text as="span" size="sm" family="mono">
                {highlight(vessel?.mmsi ?? "", wanted)} · {highlight(vessel?.callSign ?? "", wanted)}
              </Text>
            </span>
          );
        }}
      />
      {selected === null ? (
        <Text color="text-dim">Details appear here when you choose a vessel.</Text>
      ) : (
        <section aria-label="Vessel details" aria-live="polite">
          <Box background="surface2" padding="md" radius="md">
            <Stack space="sm">
              <Text>{selected.label}</Text>
              <Text family="mono">MMSI: {selected.mmsi}</Text>
              <Text family="mono">Call sign: {selected.callSign}</Text>
            </Stack>
          </Box>
        </section>
      )}
    </Stack>
  );
}

SearchInput supplies the field, magnifier, clear action and associated hint. The caller owns the query and its meaning. SearchSuggestions adds an inline list with keyboard and pointer selection; matching records and the status message come from the caller. Both render without an application provider or a network connection.

Examples

Filter a visible list

4 records shown.
  • Anchor watch
  • Anchor windlass service
  • Engine maintenance
  • Radio check
Type anchor to narrow the list, zzzz for no matches, and clear to restore every record. The field keeps normal search semantics.
Code
import { SearchInput } from "@bosunkit/components/search-input";
import { Label } from "@bosunkit/components/label";
import { Stack } from "@bosunkit/components/stack";
import { Text } from "@bosunkit/components/text";
import { useId, useState, type ChangeEvent, type Dispatch, type ReactElement, type SetStateAction } from "react";

const RECORDS: readonly string[] = ["Anchor watch", "Anchor windlass service", "Engine maintenance", "Radio check"];
export const description: string =
  "Type anchor to narrow the list, zzzz for no matches, and clear to restore every record. The field keeps normal search semantics.";

export default function Filter(): ReactElement {
  const id: string = useId();
  const [query, setQuery]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
  const shown: readonly string[] = RECORDS.filter((record: string): boolean =>
    record.toLowerCase().includes(query.trim().toLowerCase()),
  );
  return (
    <Stack space="sm">
      <Label htmlFor={id}>Filter the records</Label>
      <SearchInput
        id={id}
        value={query}
        onChange={(event: ChangeEvent<HTMLInputElement>): void => {
          setQuery(event.target.value);
        }}
        onClear={
          query === ""
            ? undefined
            : (): void => {
                setQuery("");
              }
        }
        clearLabel="Clear the record filter"
      />
      <span role="status" aria-atomic="true">
        {shown.length === 0
          ? "No records match. Try fewer words or clear the filter."
          : `${String(shown.length)} ${shown.length === 1 ? "record" : "records"} shown.`}
      </span>
      <ul aria-label="Filtered records">
        {shown.map((record: string): ReactElement => (
          <li key={record}>
            <Text as="span">{record}</Text>
          </li>
        ))}
      </ul>
    </Stack>
  );
}

Use a plain search field when typing narrows content already on the page. Clearing restores every record. An empty query is different from a query with no matches, and the result count updates in a polite live region.

Choose a result with the keyboard

3 matches for anchor
Type windlass, or use the arrow keys and Enter to choose a record. Escape dismisses the list and keeps your query.
Code
import { SearchSuggestions, type SearchSuggestion } from "@bosunkit/components/search-input";
import { Box } from "@bosunkit/components/box";
import { Stack } from "@bosunkit/components/stack";
import { Text } from "@bosunkit/components/text";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";

const RECORDS: readonly SearchSuggestion[] = [
  { value: "watch", label: "Anchor watch" },
  { value: "windlass", label: "Anchor windlass service" },
  { value: "plan", label: "Anchorage plan" },
];
const MATCH_STYLE: CSSProperties = { background: "var(--accent-dim)", color: "inherit", fontWeight: "bold" };

export const description: string =
  "Type windlass, or use the arrow keys and Enter to choose a record. Escape dismisses the list and keeps your query.";

export default function Results(): ReactElement {
  const [query, setQuery]: [string, Dispatch<SetStateAction<string>>] = useState<string>("anchor");
  const [chosen, setChosen]: [SearchSuggestion | null, Dispatch<SetStateAction<SearchSuggestion | null>>] =
    useState<SearchSuggestion | null>(null);
  const wanted: string = query.trim().toLowerCase();
  const shown: readonly SearchSuggestion[] =
    wanted === ""
      ? []
      : RECORDS.filter((entry: SearchSuggestion): boolean => entry.label.toLowerCase().includes(wanted));
  const status: string =
    wanted === ""
      ? "Type to search the records."
      : shown.length === 0
        ? "No matches. Try fewer words."
        : `${String(shown.length)} ${shown.length === 1 ? "match" : "matches"} for ${query}`;
  return (
    <Stack space="md">
      <SearchSuggestions
        label="Search the records"
        value={query}
        onValueChange={setQuery}
        items={shown}
        onSelect={setChosen}
        clearLabel="Clear the search"
        status={status}
        renderItem={(item: SearchSuggestion): ReactElement => {
          const start: number = item.label.toLowerCase().indexOf(wanted);
          return (
            <span>
              {item.label.slice(0, start)}
              <mark style={MATCH_STYLE}>{item.label.slice(start, start + wanted.length)}</mark>
              {item.label.slice(start + wanted.length)}
            </span>
          );
        }}
      />
      {chosen === null ? null : (
        <section aria-label="Selected record">
          <Box background="surface2" padding="md" radius="md">
            <Text>{chosen.label}</Text>
          </Box>
        </section>
      )}
    </Stack>
  );
}

Focus or click the field, use the arrow keys to move through suggestions, and press Enter to choose one. A click or tap chooses the same result. Escape dismisses the list and keeps the query; Tab leaves the field without choosing a result. The field keeps focus while moving through suggestions and exposes the active option to assistive technology. Each instance has its own field and result IDs.

Search on submit

Matches literal text, ignoring letter case. Quotes, |, * and - have no special meaning in this example.Enter a query, then search.
    Type anchor, then press Enter or Search records. Results change only when submitted. This example matches literal text and ignores letter case.
    Code
    import { SearchInput } from "@bosunkit/components/search-input";
    import { Button } from "@bosunkit/components/button";
    import { Label } from "@bosunkit/components/label";
    import { Stack } from "@bosunkit/components/stack";
    import {
      useId,
      useState,
      type ChangeEvent,
      type Dispatch,
      type SubmitEvent,
      type ReactElement,
      type SetStateAction,
    } from "react";
    
    const RECORDS: readonly string[] = ["Anchor watch", "Anchor windlass service", "Engine maintenance"];
    export const description: string =
      "Type anchor, then press Enter or Search records. Results change only when submitted. This example matches literal text and ignores letter case.";
    
    export default function Syntax(): ReactElement {
      const id: string = useId();
      const [draft, setDraft]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
      const [query, setQuery]: [string | null, Dispatch<SetStateAction<string | null>>] = useState<string | null>(null);
      const shown: readonly string[] =
        query === null || query === ""
          ? []
          : RECORDS.filter((record: string): boolean => record.toLowerCase().includes(query.toLowerCase()));
      return (
        <form
          role="search"
          aria-label="Submitted record search"
          onSubmit={(event: SubmitEvent<HTMLFormElement>): void => {
            event.preventDefault();
            setQuery(draft.trim());
          }}
        >
          <Stack space="sm">
            <Label htmlFor={id}>Search records on submit</Label>
            <SearchInput
              id={id}
              value={draft}
              onChange={(event: ChangeEvent<HTMLInputElement>): void => {
                setDraft(event.target.value);
              }}
              hint="Matches literal text, ignoring letter case. Quotes, |, * and - have no special meaning in this example."
              onClear={
                draft === ""
                  ? undefined
                  : (): void => {
                      setDraft("");
                      setQuery(null);
                    }
              }
              clearLabel="Clear the submitted search"
            />
            <Button type="submit" disabled={draft.trim() === ""} motion={false}>
              Search records
            </Button>
            <span role="status" aria-atomic="true">
              {query === null || query === ""
                ? "Enter a query, then search."
                : shown.length === 0
                  ? "No matches. Try fewer words."
                  : `${String(shown.length)} ${shown.length === 1 ? "match" : "matches"} for ${query}.`}
            </span>
            <ul aria-label="Submitted results">
              {shown.map((record: string): ReactElement => (
                <li key={record}>{record}</li>
              ))}
            </ul>
          </Stack>
        </form>
      );
    }
    

    Use a form when a query should run only after Enter or the search button. The draft and submitted query are separate, so editing the field does not silently change the displayed results. Clearing resets both.

    Loading and recovery

    Example controls: choose how the local directory responds.

    Search for anchor or engine. Change the query during a slow response to cancel that lookup.Type to search the example directory.
    Choose a slow or failed response, then search for anchor. Retry keeps your query; changing it cancels the previous lookup.
    Code
    import { SearchSuggestions, type SearchSuggestion } from "@bosunkit/components/search-input";
    import { Button } from "@bosunkit/components/button";
    import { Inline } from "@bosunkit/components/inline";
    import { SectionMessage } from "@bosunkit/components/section-message";
    import { Stack } from "@bosunkit/components/stack";
    import { Text } from "@bosunkit/components/text";
    import {
      useEffect,
      useRef,
      useState,
      type Dispatch,
      type ReactElement,
      type RefObject,
      type SetStateAction,
    } from "react";
    
    export const description: string =
      "Choose a slow or failed response, then search for anchor. Retry keeps your query; changing it cancels the previous lookup.";
    
    type ResponseMode = "normal" | "slow" | "failed";
    export type DirectoryLookup = (
      query: string,
      signal: AbortSignal,
      mode: ResponseMode,
    ) => Promise<readonly SearchSuggestion[]>;
    type Result = { query: string; mode: ResponseMode; attempt: number } & (
      { phase: "ready"; items: readonly SearchSuggestion[] } | { phase: "failed" }
    );
    const RECORDS: readonly SearchSuggestion[] = [
      { value: "watch", label: "Anchor watch" },
      { value: "windlass", label: "Anchor windlass service" },
      { value: "engine", label: "Engine maintenance" },
    ];
    
    /** A local directory with a controllable delay and failure; no network or vessel data. */
    const lookupDirectory: DirectoryLookup = (
      query: string,
      signal: AbortSignal,
      mode: ResponseMode,
    ): Promise<readonly SearchSuggestion[]> =>
      new Promise((resolve: (items: readonly SearchSuggestion[]) => void, reject: (error: Error) => void): void => {
        if (signal.aborted) {
          reject(new Error("Lookup canceled"));
          return;
        }
        const cancel: () => void = (): void => {
          window.clearTimeout(timer);
          reject(new Error("Lookup canceled"));
        };
        const timer: number = window.setTimeout(
          (): void => {
            signal.removeEventListener("abort", cancel);
            if (mode === "failed") reject(new Error("Example directory unavailable"));
            else resolve(RECORDS.filter((record: SearchSuggestion): boolean => record.label.toLowerCase().includes(query)));
          },
          mode === "slow" ? 1600 : 500,
        );
        signal.addEventListener("abort", cancel, { once: true });
      });
    
    /** The injectable lookup lets the browser test resolve requests out of order. */
    export default function Remote({ lookup = lookupDirectory }: { lookup?: DirectoryLookup }): ReactElement {
      const [query, setQuery]: [string, Dispatch<SetStateAction<string>>] = useState<string>("");
      const [mode, setMode]: [ResponseMode, Dispatch<SetStateAction<ResponseMode>>] = useState<ResponseMode>("normal");
      const [attempt, setAttempt]: [number, Dispatch<SetStateAction<number>>] = useState<number>(0);
      const [result, setResult]: [Result | null, Dispatch<SetStateAction<Result | null>>] = useState<Result | null>(null);
      const [chosen, setChosen]: [SearchSuggestion | null, Dispatch<SetStateAction<SearchSuggestion | null>>] =
        useState<SearchSuggestion | null>(null);
      const field: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement | null>(null);
      const wanted: string = query.trim().toLowerCase();
      const current: Result | null =
        result?.query === wanted && result.mode === mode && result.attempt === attempt ? result : null;
      useEffect((): (() => void) | undefined => {
        if (wanted === "") return undefined;
        const controller: AbortController = new AbortController();
        // A transport may finish after abort. Only the current effect may publish its response.
        void lookup(wanted, controller.signal, mode).then(
          (items: readonly SearchSuggestion[]): void => {
            if (!controller.signal.aborted) setResult({ query: wanted, mode, attempt, phase: "ready", items });
          },
          (): void => {
            if (!controller.signal.aborted) setResult({ query: wanted, mode, attempt, phase: "failed" });
          },
        );
        return (): void => {
          controller.abort();
        };
      }, [wanted, mode, attempt, lookup]);
      const items: readonly SearchSuggestion[] = current?.phase === "ready" ? current.items : [];
      const status: string =
        wanted === ""
          ? "Type to search the example directory."
          : current === null
            ? "Searching the example directory…"
            : current.phase === "failed"
              ? "Couldn't search because the example directory is unavailable. Your query is kept. Retry the search."
              : items.length === 0
                ? "No matches. Try fewer words."
                : `${String(items.length)} ${items.length === 1 ? "match" : "matches"}.`;
      return (
        <Stack space="md">
          <Text size="sm">Example controls: choose how the local directory responds.</Text>
          <Inline space="sm">
            <Button
              type="button"
              aria-pressed={mode === "normal"}
              onClick={(): void => {
                setMode("normal");
              }}
              motion={false}
            >
              Normal response
            </Button>
            <Button
              type="button"
              aria-pressed={mode === "slow"}
              onClick={(): void => {
                setMode("slow");
              }}
              motion={false}
            >
              Slow response
            </Button>
            <Button
              type="button"
              aria-pressed={mode === "failed"}
              onClick={(): void => {
                setMode("failed");
              }}
              motion={false}
            >
              Failed response
            </Button>
          </Inline>
          <div ref={field}>
            <SearchSuggestions
              label="Search the directory"
              value={query}
              onValueChange={(next: string): void => {
                setQuery(next);
                setAttempt((previous: number): number => previous + 1);
              }}
              items={items}
              onSelect={setChosen}
              status={status}
              clearLabel="Clear the directory search"
              hint="Search for anchor or engine. Change the query during a slow response to cancel that lookup."
            />
          </div>
          {wanted !== "" && current?.phase === "failed" ? (
            <SectionMessage kind="error" title="The example directory is unavailable">
              <Button
                type="button"
                onClick={(): void => {
                  setMode("normal");
                  setAttempt((previous: number): number => previous + 1);
                  field.current?.querySelector("input")?.focus();
                }}
                motion={false}
              >
                Retry the search
              </Button>
            </SectionMessage>
          ) : null}
          {chosen === null ? null : (
            <section aria-label="Directory record">
              <Text>{chosen.label}</Text>
            </section>
          )}
        </Stack>
      );
    }
    

    The example controls simulate responses locally. A changed query cancels the previous lookup; a response arriving after cancellation cannot replace the current results. Pending and unsuccessful searches have distinct messages. Retry preserves the query and returns focus to the field.

    Empty, disabled and read-only fields

    Search by the vessel name, its MMSI or its call sign. This example matches literal text and ignores letter case.
    Edit or clear the first query. The disabled and read-only fields keep their text. A wrapped hint leaves the clear button aligned with the field.
    Code
    import { SearchInput } from "@bosunkit/components/search-input";
    import { Label } from "@bosunkit/components/label";
    import { Stack } from "@bosunkit/components/stack";
    import { useId, useState, type ChangeEvent, type Dispatch, type ReactElement, type SetStateAction } from "react";
    
    export const description: string =
      "Edit or clear the first query. The disabled and read-only fields keep their text. A wrapped hint leaves the clear button aligned with the field.";
    
    export default function States(): ReactElement {
      const id: string = useId();
      const [query, setQuery]: [string, Dispatch<SetStateAction<string>>] = useState<string>("Windgrazer");
      const clear: () => void = (): void => {
        setQuery("");
      };
      return (
        <Stack space="md">
          <Stack space="sm">
            <Label htmlFor={`${id}-editable`}>Editable vessel search</Label>
            <SearchInput
              id={`${id}-editable`}
              value={query}
              onChange={(event: ChangeEvent<HTMLInputElement>): void => {
                setQuery(event.target.value);
              }}
              onClear={query === "" ? undefined : clear}
              clearLabel="Clear the editable search"
              hint="Search by the vessel name, its MMSI or its call sign. This example matches literal text and ignores letter case."
            />
          </Stack>
          <Stack space="sm">
            <Label htmlFor={`${id}-disabled`}>Disabled vessel search</Label>
            <SearchInput
              id={`${id}-disabled`}
              defaultValue="Althea"
              disabled
              onClear={clear}
              clearLabel="Clear the disabled search"
            />
          </Stack>
          <Stack space="sm">
            <Label htmlFor={`${id}-readonly`}>Read-only vessel search</Label>
            <SearchInput
              id={`${id}-readonly`}
              defaultValue="Sea Lark"
              readOnly
              onClear={clear}
              clearLabel="Clear the read-only search"
            />
          </Stack>
        </Stack>
      );
    }
    

    The caller clears the value through onClear; focus returns to the input. A disabled or read-only field also disables its clear button. The button stays beside the field when the hint wraps, and a custom clear button takes the place of the browser's duplicate clear control. Omit onClear when the query is empty.

    Explain the supported query syntax

    The examples match literal text and ignore letter case. hint describes the actual search implementation; the input itself does not parse quotes, alternatives, prefixes or exclusions. If a backend supports those operators, describe them in its caller's hint and show parsing errors beside the field with a correction the reader can make.

    Props

    PropTypeDefaultDescription
    classNamestring | undefined–Additional input classes.
    unitstring | undefined–The unit of the value, inside the field's right edge (m, kn, °T), as SN.1/Circ.243 writes it. It is read after the value, through aria-describedby, so the field's name need not repeat it.
    refRef<HTMLInputElement> | undefined–The input element.
    hintReactNode | undefined–Guidance under the field: the syntax the search reads, translated by the caller ("Quotes keep words together, | joins alternatives, * matches a prefix, a leading - excludes"). The crew write better queries when the field says how it reads them, and a screen reader hears it: the field describes itself by the hint.
    onClear(() => void) | undefined–Called when the crew press clear; the caller clears its value and focus returns to the input.
    clearLabelstring | undefined–The clear button's name, translated by the caller. With onClear it draws, disabled for disabled/readOnly fields.
    onValueChange((value: string, eventDetails: Input.ChangeEventDetails) => void) | undefined–Callback fired when the value changes. Use when controlled.
    defaultValueField.Control.Props['defaultValue'] | undefined–The default value of the input. Use when uncontrolled.
    valueReact.ComponentProps<'input'>['value'] | undefined–The value of the input. Use when controlled.
    renderReact.ReactElement | ComponentRenderFn<RenderFunctionProps, State> | undefined–Allows you to replace the component's HTML element with a different tag, or compose it with another component. Accepts a ReactElement or a function that returns the element to render.
    styleReact.CSSProperties | ((state: State) => React.CSSProperties | undefined) | undefined–Style applied to the element, or a function that returns a style object based on the component's state.

    Also takes every attribute of Omit<InputProps, "type" | "icon">.

    PropTypeDefaultDescription
    labelrequiredstring–The visible label naming the field and its suggestions.
    valuerequiredstring–The query held by the caller.
    onValueChangerequired(value: string) => void–Called when typing or clearing changes the query. Choosing a result does not replace the query.
    itemsrequiredreadonly SearchSuggestion[]–Matching results supplied in display order; the caller owns filtering and fetching.
    onSelectrequired(item: SearchSuggestion) => void–Called when Enter or a pointer chooses a result.
    statusrequiredstring–A complete, translated count, empty-state or pending message, announced politely.
    clearLabelrequiredstring–The clear button's accessible name.
    hintReactNode | undefined–Guidance associated with the field, translated by the caller.
    placeholderstring | undefined–Text shown inside the empty field.
    renderItem((item: SearchSuggestion) => ReactNode) | undefined–Optional result content, such as a highlighted name and its identifying code.
    testIdstring | undefined–Becomes data-testid on the root.
    • Input — field presentation and form integration.
    • Combobox — choosing a field value from suggestions.
    • Section message — explaining a failed lookup beside its controls.