Skip to content

Search field

A compact search input with a leading glyph; clear buttons and key hints go in its children.

Status
Stable
Category
Inputs
Adoption
Not used yet
import { SearchField } from "@oration/canon/components/search-field";
packages/canon/src/components/search-field.tsx

5 of 5 suppliers

  • Northwind FreightV-004417$48,250.00
  • Halcyon LogisticsV-003182$12,480.00
  • Orchard Street ProduceV-005590$3,915.40
  • Bellweather PackagingV-002047$27,000.00
  • Juniper Office SupplyV-006103$642.18
import { Button } from "@oration/canon/components/button";import { SearchField } from "@oration/canon/components/search-field";import * as React from "react";export function Hero() {    const suppliers = [        { name: "Northwind Freight", id: "V-004417", open: "$48,250.00" },        { name: "Halcyon Logistics", id: "V-003182", open: "$12,480.00" },        { name: "Orchard Street Produce", id: "V-005590", open: "$3,915.40" },        { name: "Bellweather Packaging", id: "V-002047", open: "$27,000.00" },        { name: "Juniper Office Supply", id: "V-006103", open: "$642.18" },    ];    const [query, setQuery] = React.useState("");    const q = query.trim().toLowerCase();    const rows = suppliers.filter(        (s) =>            !q ||            s.name.toLowerCase().includes(q) ||            s.id.toLowerCase().includes(q),    );    return (        <div className="flex w-full max-w-xl flex-col overflow-hidden rounded-xl bg-card text-left shadow-border">            <div className="flex items-center gap-2 border-b border-border px-3 py-2.5">                <SearchField                    value={query}                    onChange={setQuery}                    placeholder="Search name or vendor ID"                    label="Search suppliers"                    className="w-full sm:w-64"                />                <p                    role="status"                    className="ml-auto shrink-0 text-xs text-muted-foreground tabular-nums"                >                    {rows.length} of {suppliers.length} suppliers                </p>            </div>            {rows.length ? (                <ul className="flex flex-col p-1">                    {rows.map((row) => (                        <li                            key={row.id}                            className="flex h-9 items-center gap-3 rounded-lg px-2.5 text-13 hover:bg-muted"                        >                            <span className="min-w-0 flex-1 truncate font-medium text-foreground">                                {row.name}                            </span>                            <span className="font-mono text-xs text-muted-foreground">                                {row.id}                            </span>                            <span className="w-24 text-right text-foreground tabular-nums">                                {row.open}                            </span>                        </li>                    ))}                </ul>            ) : (                <div className="flex flex-col items-center gap-2 px-4 py-8 text-center">                    <p className="text-sm font-medium text-foreground">                        No suppliers match “{query.trim()}”                    </p>                    <p className="text-13 text-muted-foreground">                        Search matches supplier names and vendor IDs.                    </p>                    <Button                        type="button"                        variant="outline"                        size="sm"                        onClick={() => setQuery("")}                    >                        Clear search                    </Button>                </div>            )}        </div>    );}

Usage#

Search field is the compact filter box at the start of a list toolbar: a 14px search glyph, a 28px input and a required accessible label. It filters as people type, with a controlled value and an onChange that hands you the string; there is no submit. It is deliberately bare. A clear button or a shortcut hint goes in children, absolutely positioned, and you widen the input's padding yourself through inputClassName, which replaces the default classes rather than adding to them.

When to use

  • To filter the rows of a list, table or grid in place: agents, conversations, suppliers, cohorts.
  • At the start of a toolbar row, before filters and view options, at w-full sm:w-64.
  • At the top of a panel or popover list, such as chat history, at 32px.
  • With a clear button once there is a query, and Escape to clear.

When not to use

  • To search the whole workspace or jump to a page. That is the command palette. Use Command menu
  • To pick one value from a long list for a form field. Use Combobox
  • For a form field that happens to have a search icon, with a visible label and an error. Use Input group
  • For structured filters such as status or tier. Use Filter chip

The Thirteen-Fourteen Rule

Toolbar search is dense UI: 13px text in a 28px control, beside 28px toolbar buttons. Below 768px it grows to 32px and 16px so phones don't zoom.

Filtered to nothing says why

When a query hides every row, the empty state names the query and offers Clear search, not a bare No results.

Anatomy#

  1. Wrapper. A relative div that takes className, usually a width such as w-full sm:w-64.
  2. Glyph. A 14px lucide search icon in Slate Meta, 10px from the left edge, hidden from assistive tech.
  3. Input. An Input at 28px with 32px of left padding for the glyph. It is meant to be 13px; see the gaps. label becomes its aria-label.
  4. Trailing slot. children, positioned by you: a clear button or a key hint. Add right padding to the input so text doesn't run under it.

Examples#

With a clear button

Pass the button as children, position it at the right, and widen the input's right padding. Escape clears too, wired through onKeyDown.

import { Button } from "@oration/canon/components/button";import { SearchField } from "@oration/canon/components/search-field";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { XIcon } from "lucide-react";import * as React from "react";export function WithClear() {    const [query, setQuery] = React.useState("remittance");    return (        <SearchField            value={query}            onChange={setQuery}            placeholder="Search the transcript"            label="Search the transcript"            className="w-full max-w-xs"            inputClassName="h-8 pr-8 pl-8 md:text-[13px]"            onKeyDown={(event) => {                if (event.key === "Escape") setQuery("");            }}        >            {query ? (                <Tooltip>                    <TooltipTrigger                        render={                            <Button                                type="button"                                variant="ghost"                                size="icon-xs"                                aria-label="Clear search"                                onClick={(event) => {                                    setQuery("");                                    event.currentTarget.parentElement                                        ?.querySelector("input")                                        ?.focus();                                }}                                className="absolute top-1/2 right-1 -translate-y-1/2 text-muted-foreground"                            />                        }                    >                        <XIcon />                    </TooltipTrigger>                    <TooltipContent>Clear search</TooltipContent>                </Tooltip>            ) : null}        </SearchField>    );}

With a shortcut hint

A key hint in the trailing slot, hidden once there is a query. Press / anywhere outside a text field to focus it.

/
import { Kbd } from "@oration/canon/components/kbd";import { SearchField } from "@oration/canon/components/search-field";import * as React from "react";export function ShortcutHint() {    const wrapper = React.useRef<HTMLDivElement>(null);    const [query, setQuery] = React.useState("");    React.useEffect(() => {        const onKey = (event: KeyboardEvent) => {            const target = event.target as HTMLElement | null;            const typing =                target?.closest("input, textarea, [contenteditable='true']") !==                null;            if (event.key !== "/" || typing) return;            event.preventDefault();            wrapper.current?.querySelector("input")?.focus();        };        window.addEventListener("keydown", onKey);        return () => window.removeEventListener("keydown", onKey);    }, []);    return (        <div ref={wrapper} className="w-full max-w-64">            <SearchField                value={query}                onChange={setQuery}                placeholder="Search invoices"                label="Search invoices"                inputClassName="h-7 pr-8 pl-8 max-md:h-8 md:text-[13px]"            >                {query ? null : (                    <Kbd className="absolute top-1/2 right-1.5 -translate-y-1/2">                        /                    </Kbd>                )}            </SearchField>        </div>    );}

Sizes

28px in toolbars beside 28px buttons; 32px with a 16px glyph at the top of a panel or popover. Both grow to 32px and 16px text on phones.

Toolbar, 28px
Panel, 32px
import { SearchField } from "@oration/canon/components/search-field";import * as React from "react";export function Sizes() {    const [toolbar, setToolbar] = React.useState("");    const [panel, setPanel] = React.useState("");    return (        <div className="flex w-full max-w-lg flex-col gap-6 sm:flex-row sm:items-end">            <div className="flex min-w-0 flex-1 flex-col gap-2">                <span className="text-xs text-muted-foreground">                    Toolbar, 28px                </span>                <SearchField                    value={toolbar}                    onChange={setToolbar}                    placeholder="Search payment runs"                    label="Search payment runs"                />            </div>            <div className="flex min-w-0 flex-1 flex-col gap-2">                <span className="text-xs text-muted-foreground">                    Panel, 32px                </span>                <SearchField                    value={panel}                    onChange={setPanel}                    placeholder="Search chats"                    label="Search chats"                    inputClassName="h-8 pl-8 md:text-[13px]"                    iconClassName="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"                />            </div>        </div>    );}

States#

Rest
Focus
Filled
import { Button } from "@oration/canon/components/button";import { SearchField } from "@oration/canon/components/search-field";import { cn } from "@oration/canon/lib/utils";import { XIcon } from "lucide-react";export function StatesRow() {    const states = [        { name: "Rest", value: "", input: "" },        { name: "Focus", value: "", input: "border-ring ring-3 ring-ring/50" },        { name: "Filled", value: "Halcyon", input: "" },    ];    return (        <div className="grid w-full grid-cols-1 gap-6 sm:grid-cols-3">            {states.map((state) => (                <div                    key={state.name}                    className="pointer-events-none flex min-w-0 flex-col gap-2"                >                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <SearchField                        value={state.value}                        onChange={() => undefined}                        placeholder="Search suppliers"                        label={`Search suppliers, ${state.name}`}                        inputClassName={cn(                            "h-7 pr-8 pl-8 md:text-[13px]",                            state.input,                        )}                    >                        {state.value ? (                            <Button                                type="button"                                variant="ghost"                                size="icon-xs"                                tabIndex={-1}                                aria-label="Clear search"                                className="absolute top-1/2 right-0.5 size-6 -translate-y-1/2 text-muted-foreground"                            >                                <XIcon />                            </Button>                        ) : null}                    </SearchField>                </div>            ))}        </div>    );}
States
StateTreatment
RestField Stroke border, Slate Meta placeholder.
Focus visibleThe input's indigo border and 3px ring at 50%.
FilledThe query in Graphite Ink. Show a clear button in the trailing slot.
No resultsOwned by the list below: name the query and offer to clear it.
DisabledNot supported by props today; there is no disabled pass-through.

Behavior#

  • Controlled only: pass value and onChange(value: string). Filter on every change; debounce only when the filter is expensive.
  • onKeyDown reaches the input, so Escape can clear the query or ArrowDown can move into a result list.
  • type reaches the input. type="search" adds the browser's own clear control in Chromium and Safari; don't pair it with your own clear button.
  • inputClassName and iconClassName replace the defaults. Copy the recipe and change one part: for a clear button, h-7 pr-8 pl-8 max-md:h-8 md:text-[13px].
  • Below 768px the input is 32px at 16px text, so iOS doesn't zoom on focus.
  • There is no ref or id. To focus it from a shortcut, find the input inside a wrapper ref.

Do and don't#

Do. Give it a label that names what is searched, such as Search suppliers, and a placeholder that hints at what matches.
Don't. Rely on the placeholder alone, or label every field Search, so a screen reader can't tell two apart.

No suppliers match “zenith”

Do. When the query hides everything, say which query and offer Clear search.

No results

Don't. Show a bare No results with no way back.

Content#

  • Labels are Search plus the thing: Search suppliers, Search the transcript.
  • Placeholders list what matches, in sentence case with no ellipsis: Search name, phone or vendor ID.
  • No-result copy quotes the query: No suppliers match “zenith”.
  • Name the clear button for what it clears: Clear search.

Accessibility#

  • label is required and becomes the input's aria-label. There is no visible label, so make it specific.
  • The glyph is aria-hidden; the name comes from label alone.
  • Announce result counts in a polite live region (role="status") near the list, such as 12 suppliers.
  • A clear button needs an aria-label and a tooltip, and should return focus to the input.
  • Don't autofocus a toolbar search on page load; it hides the page heading from screen reader users.
Keyboard interactions
KeysAction
TabMoves focus to the input.
EscClears the query when you wire it through onKeyDown, as the transcript search does.
/A common page shortcut to focus search. Show it as a key hint in the trailing slot.

Design tokens#

Design tokens
TokenUsed for
--inputInput stroke
--ringFocus border and 3px ring at 50%
--muted-foregroundGlyph and placeholder
--mutedKey hint fill
--radius-lg10px corners

API reference#

SearchField

A search input with a leading glyph. Other input attributes are not forwarded.

Props of SearchField
PropTypeDefaultDescription
valueRequiredstringNo defaultThe query.
onChangeRequired(value: string) => voidNo defaultCalled with the new query on every keystroke.
labelRequiredstringNo defaultThe input's accessible name.
placeholderRequiredstringNo defaultWhat people can search by.
onKeyDownReact.KeyboardEventHandler<HTMLInputElement>No defaultKey handler on the input, for Escape to clear or arrows into results.
typeReact.HTMLInputTypeAttributeNo defaultThe input type. "search" adds the browser clear control.
classNamestringNo defaultOn the wrapper, usually a width.
inputClassNamestring"h-7 pl-8 text-13 max-md:h-8 max-md:text-base"Replaces the input's classes.
iconClassNamestring"pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"Replaces the glyph's classes.
childrenReactNodeNo defaultRendered after the input inside the relative wrapper: a clear button or a key hint.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

There is no built-in clear button or shortcut hint. The registry describes both; today they are children you position yourself.

The default input renders at 14px from 768px up, not the 13px it asks for. The cn inside packages/canon reads text-13 as a color, so Input's md:text-sm survives the merge and wins. Pass md:text-[13px] in inputClassName, as the examples on this page do; the hero shows the default.

inputClassName and iconClassName replace the default classes instead of merging with them, so a call site that only wants pr-8 loses the height, padding and 13px size.

No id, ref, name, disabled or autoFocus pass-through. The breadcrumb switcher ships its own search field (apps/web/src/components/shell/breadcrumb-switcher/search-field.tsx) and the AI governance kit wraps this one to force type="search".