Search field
A compact search input with a leading glyph; clear buttons and key hints go in its children.
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
Filtered to nothing says why
Anatomy#
- Wrapper. A
relativediv that takesclassName, usually a width such asw-full sm:w-64. - Glyph. A 14px lucide search icon in Slate Meta, 10px from the left edge, hidden from assistive tech.
- Input. An
Inputat 28px with 32px of left padding for the glyph. It is meant to be 13px; see the gaps.labelbecomes itsaria-label. - 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.
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#
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> );}| State | Treatment |
|---|---|
| Rest | Field Stroke border, Slate Meta placeholder. |
| Focus visible | The input's indigo border and 3px ring at 50%. |
| Filled | The query in Graphite Ink. Show a clear button in the trailing slot. |
| No results | Owned by the list below: name the query and offer to clear it. |
| Disabled | Not supported by props today; there is no disabled pass-through. |
Behavior#
- Controlled only: pass
valueandonChange(value: string). Filter on every change; debounce only when the filter is expensive. onKeyDownreaches the input, so Escape can clear the query or ArrowDown can move into a result list.typereaches the input.type="search"adds the browser's own clear control in Chromium and Safari; don't pair it with your own clear button.inputClassNameandiconClassNamereplace 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
reforid. To focus it from a shortcut, find the input inside a wrapper ref.
Do and don't#
label that names what is searched, such as Search suppliers, and a placeholder that hints at what matches.No suppliers match “zenith”
No results
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#
labelis required and becomes the input'saria-label. There is no visible label, so make it specific.- The glyph is
aria-hidden; the name comes fromlabelalone. - Announce result counts in a polite live region (
role="status") near the list, such as 12 suppliers. - A clear button needs an
aria-labeland 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.
| Keys | Action |
|---|---|
| Tab | Moves focus to the input. |
| Esc | Clears 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#
| Token | Used for |
|---|---|
--input | Input stroke |
--ring | Focus border and 3px ring at 50% |
--muted-foreground | Glyph and placeholder |
--muted | Key hint fill |
--radius-lg | 10px corners |
API reference#
SearchField
A search input with a leading glyph. Other input attributes are not forwarded.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | string | No default | The query. |
onChangeRequired | (value: string) => void | No default | Called with the new query on every keystroke. |
labelRequired | string | No default | The input's accessible name. |
placeholderRequired | string | No default | What people can search by. |
onKeyDown | React.KeyboardEventHandler<HTMLInputElement> | No default | Key handler on the input, for Escape to clear or arrows into results. |
type | React.HTMLInputTypeAttribute | No default | The input type. "search" adds the browser clear control. |
className | string | No default | On the wrapper, usually a width. |
inputClassName | string | "h-7 pl-8 text-13 max-md:h-8 max-md:text-base" | Replaces the input's classes. |
iconClassName | string | "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. |
children | ReactNode | No default | Rendered 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".