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.
pnpm add @tessinaui/uiWhen to use
- A large or complex set of content that people cannot scan in one view: a catalogue, an inbox, a docs site, a table.
- At any scope. Site-wide in a header, page-level for one page, or inside a component such as a data table. Name the scope in the placeholder or with a scope tab.
- A field that submits a query. Enter or the inline submit calls
onSearch, and a permanently mounted status region announces loading and result counts. - A compact toolbar slot.
expandablecollapses the field to an icon until it is needed, andvariant="ghost"keeps it quiet. - A button that opens a search surface.
SearchTriggerlooks like the field and opens a command palette (Trigger).
When not to use
- For a small set that fits on screen. If everything is visible and easy to find, a search field is only more to learn.
- For suggestions in a popup under the field. Use SearchSuggest, which is built on this field.
- To pick one option from a list. Use Combobox.
- For commands and navigation in a palette. Use Command.
- For an AI or chat composer. Use PromptInput.
- For facet tokens inside the input. Use Tokenizer.
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 SearchSuggest, built on this field.
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 touch devices so iOS Safari never zooms the page on focus.
Intents
intent + supportingText for feedback. This axis exists for parity with
Field. Use it for genuine feedback ("No results", a
syntax error in a query language), not decoration.
Error message
errorMessage replaces supportingText while present, implies intent="error", joins the input's aria-describedby and is announced politely.
On-color
tone="on-color" renders the field on a coloured or dark plate — hero bands,
brand headers.
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. On touch devices each control
grows a 44px tap extension toward the field's start, so the field spaces every
control, the clear chip included, by that extension's overhang: 20px beside the
24px chips of xs and sm, 12px beside the 32px chips of md. Every action
keeps a full 44×44 target; 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 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
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. For recents in a popup on focus, use SearchSuggest.
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
The 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 surveyed 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
RTL
dir="rtl" moves the magnifier to the right edge and the trailing slot (clear, shortcut cap or submit button) to the left. The label and helper rows align right. Leave dir unset and the field follows the page's direction.
API Reference
Search
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "filled" | "minimal" | "ghost" | "underline" | "default" | Visual variant. ghost is chromeless for toolbars and keeps the full focus ring. underline is an alias of minimal, the input family's name for the bottom-rule shell |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Heights 32/36/40/48/56; xs–md grow to the 44px floor on touch devices |
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. Wired to the input's aria-describedby |
errorMessage | ReactNode | — | Error row below the field. Replaces supportingText, implies intent="error", sets aria-invalid and is announced politely |
searchLabel | string | "Search", Arabic under dir="rtl" | Accessible name for the input when there is no label; with a label, it names the landmark instead |
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" (Arabic under dir="rtl") / "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 live 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" (Arabic under dir="rtl") / — | External text Cancel button, visible while focused or dirty |
expandable / expanded / defaultExpanded / onExpandedChange | — | false / — / false / — | Icon-only collapsed mode with the expand/collapse mechanics above |
noForm | boolean | false | <div role="search"> wrapper for use inside another form |
dir | "ltr" | "rtl" | — | Direction; layout is logical-property driven |
strings | Partial<SearchStrings> | English, Arabic under dir="rtl" | Override the clear button's name (clear), the optional marker, the defaults of searchLabel, submitButtonText, cancelLabel and placeholder ("Search..."), and the searching announcement. The props win |
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…", Arabic under dir="rtl" | Placeholder-style text; part of the accessible name |
strings | Partial<Pick<SearchStrings, "triggerLabel">> | English, Arabic under dir="rtl" | Override the default label (triggerLabel) |
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.
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 → trailing actions → clear (when visible) → submit → cancel |
Accessibility
<form role="search">landmark (or<div role="search">undernoForm). This is the only search landmark when composed into a header — the TopHeaderMobile and TopHeaderDesktopsearchslots are plain boxes and add none.searchLabelnames exactly one element, so screen readers say it once. With nolabelit names the input and the landmark stays unnamed, which is fine for the usual single search on a page. With alabel(visible orvisuallyHiddenLabel) the label names the input andsearchLabelnames the landmark. When a page has several searches, give each alabeland a uniquesearchLabelso the landmarks can be told apart.- 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 that pattern's 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 touch devices; 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 SearchSuggest or Combobox) · render
two bound showShortcut fields expecting both to fire · rely on placeholder
as the only label.