Search
A search field with four variants, tone-aware on-color rendering, extensible leading/trailing slots, submit and cancel affordances, an expandable icon-only mode, a button-shaped trigger for command palettes, and a WCAG 4.1.3 status region.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Search } from "@tessinaui/ui";<Search placeholder="Search..." onSearch={(value) => console.log(value)} />The component wraps a native <input type="search"> in a <form role="search">
landmark. It ships the mobile keyboard contract by default (enterKeyHint="search",
inputMode="search", autoComplete="off", auto-capitalise/correct off), handles
Escape to clear and Enter to submit, and announces loading and result counts
through a permanently mounted polite status region.
Boundaries. Search is the field shell. A filtered listbox is Combobox;
a command palette is Command; a chat/AI composer is PromptInput; facet
tokens inside the input are Tokenizer. A search field with a suggestions
popup is tracked as SearchSuggest (TES-158) on Base UI Autocomplete.
Examples
Default
Variants
Four visual styles — default (bordered), filled (background), minimal
(bottom border only) and ghost (chromeless, for toolbars). Ghost keeps the
full focus ring: a chromeless rest state never licenses a chromeless focus
state.
Sizes
Five sizes on the input-family height ladder (32/36/40/48/56). Input text follows the family type ladder — 12/14/14/16/16 on desktop, floored at 16px on phones so iOS Safari never zooms the page on focus.
Intents
intent + supportingText for feedback. A note on honesty: no surveyed
design system documents validation states on a search field — this axis exists
for house parity with Field. Use it for genuine feedback ("No results", a
syntax error in a query language), not decoration.
On-color
tone="on-color" renders the field on a coloured or dark plate — hero bands,
brand headers (six research apps put a white-on-brand search in the header).
Inherited automatically inside a Surface.
Leading icon
Replace the magnifier with a back chevron (pushed search screens), a semantic
icon, or null for the icon-less field.
Trailing actions
Extra controls — voice, camera, a filter toggle, a scope select — live in
trailingActions, with SearchActionsDivider marking the text-actions/modality
break. Run order in the field is fixed: shortcut cap → actions → clear →
submit. The clear button always stays adjacent to the text it clears.
Spread searchActionProps(size, rounded) onto every control in the slot.
The slot takes any node, so nothing stops you sizing an action by hand — and
then it will not match the field's own clear chip. The helper hands each
control the clear chip's box, radius and tap-extension edge at every search
size (32px at md, 40px at lg, …), which is what makes an action and the
clear × read as one family. Mirrors Field's fieldSuffixButtonProps.
<Search
trailingActions={
<IconButton {...searchActionProps("md")} variant="ghost" icon={<Mic />} aria-label="Voice search" />
}
/>Budget: at most two actions on phones (M3's cap). Each control grows a 44px
tap extension on touch viewports, so the run spaces itself at 12px below md
and every action keeps a full 43×43 exclusive hit area; a third does not fit a
compact field at 320px. If you need more, move them behind a menu or below the
field.
Scope
Scope tabs under the field (the HIG scope-bar shape) plus a scoped placeholder naming the corpus.
Filter chips
An attached chip row narrows the result set; the group owns the selection.
With submit button
showSubmitButton renders the inline submit; submitVariant="icon" is the
square icon form (the more common shape in the wild). The icon submit takes
its accessible name from submitButtonText.
Keyboard shortcut
showShortcut renders the ⌘K cap (Esc once dirty) and binds a global
Cmd/Ctrl+K focus shortcut. The binding is separable (shortcutBinding={false}),
the LAST mounted bound instance wins, and the listener never fires while
another editable control has focus.
Cancel button
The iOS pattern: a text button outside the field, visible while focused or
dirty. It clears, blurs and calls onCancel — dismissal, not just clearing.
Status and loading
The WCAG 4.1.3 contract: statusText feeds an always-mounted polite live
region ("Searching…", "12 results", "No results for …"); showStatus mirrors
it visibly. loading swaps the icon for a spinner AND announces — a silent
spinner is invisible to assistive tech.
Recent searches
Clock-keyed rows, per-row remove, section-level Clear all — as page content under the field. (A suggestions popup is TES-158.)
No results
Echo the query in quotes, offer a next step, mirror the zero count through
statusText. When a filter caused the emptiness, say so and offer to remove
the filter instead — that is a different state than a bad query.
Labelled
The Field-parity label row: label, required/optional, right-aligned
infoText, visuallyHiddenLabel.
Inside another form
Nested forms are invalid HTML. noForm renders a <div role="search">
landmark instead — Enter still submits through the keydown path and the inline
submit becomes type="button".
Expandable
Carbon mechanics: collapsed to an icon button; expanding focuses the input; focus leaving an empty field collapses it; Escape on an empty field collapses and returns focus to the trigger.
Trigger for a command palette
SearchTrigger is a button dressed as the field — the GitHub/Linear pattern
for opening a search surface. Pair it with Command.Dialog and
useCommandShortcut; the trigger carries aria-haspopup="dialog" and folds
the shortcut into its accessible name.
Skeleton
API Reference
Search
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "filled" | "minimal" | "ghost" | "default" | Visual variant. ghost is chromeless for toolbars and keeps the full focus ring |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Heights 32/36/40/48/56; xs–md grow to the 44px floor on phones |
rounded | "full" | "lg" | "md" | "sm" | "none" | "full" | Corner treatment. Ignored by minimal |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Border/ring/supporting-text semantics |
tone | "default" | "on-color" | inherited | On-color rendering for coloured plates; inherited from Surface |
label / visuallyHiddenLabel / required / optional / infoText | — | — | Field-parity label row |
supportingText | string | — | Text below the field; colour and icon follow intent |
searchLabel | string | "Search" | Accessible name for the landmark (and the input when no label) |
clearable / onClear | — | true / — | Clear button (tab-reachable). Clearing dispatches a real change event, then refocuses the input |
onSearch | (value: string) => void | — | Enter or submit button |
onValueChange | (value: string) => void | — | Canonical string change callback (native onChange also forwarded) |
showSubmitButton / submitButtonText / submitVariant | — | false / "Search" / "label" | Inline submit; "icon" renders the square icon form named by submitButtonText |
loading | boolean | false | Spinner in the leading slot + aria-busy + polite "Searching…" announcement |
statusText / showStatus | string / boolean | — / false | Feeds the always-mounted polite <output> region; showStatus renders it visibly too |
leadingIcon | ReactNode | null | magnifier | Replace or (null) remove the leading slot |
trailingActions | ReactNode | — | Extra controls between the shortcut cap and the clear button |
showShortcut / shortcutBinding | boolean | false / true | ⌘K cap; binding separable, last-mounted-wins, typing-aware |
showCancelButton / cancelLabel / onCancel | — | false / "Cancel" / — | External iOS-style cancel, visible while focused or dirty |
expandable / expanded / defaultExpanded / onExpandedChange | — | false / — / false / — | Icon-only collapsed mode with Carbon expand/collapse mechanics |
noForm | boolean | false | <div role="search"> wrapper for use inside another form |
dir | "ltr" | "rtl" | — | Direction; layout is logical-property driven |
wrapperClassName / containerClassName / className | string | — | Wrapper / container / input class hooks |
Native input attributes are forwarded. The mobile defaults
(enterKeyHint="search", inputMode="search", autoComplete="off",
autoCapitalize="none", autoCorrect="off", spellCheck={false}) are plain
props — pass your own to override.
SearchTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Search…" | Placeholder-style text; part of the accessible name |
showShortcut | boolean | true | Renders the ⌘K cap and appends "(⌘K)" to the accessible name |
variant / size / rounded / intent / tone | — | — | Same shell axes as Search |
Plus all native button props. The trigger never binds the shortcut itself —
that belongs to the surface it opens (useCommandShortcut).
SearchSkeleton
size · rounded · variant · showLabel · showHelper — same box as the
real field at every combination (§2b parity).
Keyboard Interactions
| Key | Action |
|---|---|
Enter | Submits (calls onSearch); consumer onKeyDown runs first and may preventDefault() |
Escape | Clears a dirty field; collapses an empty expandable field back to its trigger |
⌘K / Ctrl+K | Focuses the field (showShortcut, unless shortcutBinding={false}); never fires while typing in another editable control |
Tab | Field → clear (when visible) → trailing actions → submit → cancel |
Accessibility
<form role="search">landmark (or<div role="search">undernoForm); give each landmark a uniquesearchLabelwhen a page has several.- With no visible
label,searchLabelnames both the landmark and the input — screen readers will read it on both; pass a visible or hiddenlabelwhen that double-speak matters in your layout. - The status region satisfies WCAG 4.1.3: it is ALWAYS in the DOM (a region
inserted at announce time is unreliable), polite, and fed by
statusText/loading. Announce counts ("12 results", "No results"), not the result list itself. - The clear button is a real tab stop that returns focus to the input and dispatches a native change event — form libraries observe clears.
- The ⌘K cap is decorative (
aria-hidden); the binding is modifier-based, so WCAG 2.1.4 does not apply. Never make a bare-key shortcut the only path. SearchTriggerfollows the GitHub-pattern contract: a button (aria-haspopup="dialog"), shortcut folded into the accessible name (2.5.3 Label in Name), cap decorative.- Touch targets: the container floors at 44px on phones; the inner clear chip carries its own tap extension.
Agent notes (AI-first)
Every part is addressable: data-slot = search-root · search-container ·
search-input · search-icon · search-clear · search-shortcut ·
search-actions · search-actions-divider · search-submit ·
search-cancel · search-label · search-supporting · search-status ·
search-trigger · search-expand-trigger · search-skeleton. The root also
stamps data-component="search", data-variant, data-size, data-intent,
data-tone.
States an agent can read: [data-slot="search-status"] text (loading/result
count), aria-busy on the input while loading, aria-expanded on the
collapsed trigger.
// Copy-paste: instrumented search with status + submit
<Search
searchLabel="Search orders"
onValueChange={setQuery}
onSearch={runSearch}
loading={isPending}
statusText={isPending ? "Searching…" : `${results.length} results`}
showSubmitButton
submitVariant="icon"
/>Do: feed statusText whenever results change · use noForm inside forms ·
give concrete example-query placeholders ("Try “wireless headphones”").
Don't: bolt a listbox onto this component (that is Combobox/TES-158) · render
two bound showShortcut fields expecting both to fire · rely on placeholder
as the only label.