Search Input
Query entry with a clear button, a debounce, suggestions and recent searches. The one field where a placeholder can carry the label.
Also called Search Box, Search Bar, Query Field — in this system all of them are Search Input.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Three placements
Small inside a table toolbar, medium in a page header, large as the subject of a search page. The magnifier is always leading — trailing reads as a submit button.
Clear appears only when there is something to clear
A permanent × on an empty field is a control that does nothing, and users press it to find out. It also has to restore the full result set, not just blank the text.
Always show the result count
It is the only feedback that the query did anything, and it is what stops a user retyping a search that already worked. It belongs in a live region.
No results
Name the query, offer the way out. A blank panel makes the user wonder whether the search ran at all.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Leading magnifier, the query, and a clear button that exists only while there is a query to clear.
- Magnifier15px, leading, aria-hidden
Leading, always. A trailing magnifier reads as a submit button, and users then expect nothing to happen until they press it.
- PlaceholderNames the scope
"Search services…" not "Search". Scope is the single most useful thing the field can say, because it sets expectations before the first query fails.
- Clear button20px, conditional
Present only when there is a query. It must restore the full result set, not just blank the text — clearing the box and leaving filtered results is a bug users cannot diagnose.
- Shortcut hintKbd, trailing, when empty
Swaps out for the clear button once typing starts. It is how anyone learns the shortcut exists.
- Debounce300ms on the request
On the fetch only. The characters appear instantly; a debounced input feels broken within two keystrokes.
- Suggestion panel6px below, full width
Matched to the field width so the two read as one control. Max 8 rows before it scrolls.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | Field fill |
| --ds-border-subtle | — | Idle border in a header |
| --ds-accent | — | Focus border |
| --ds-accent-subtle | — | Focus halo |
| --ds-fg-muted | — | Magnifier, placeholder, clear glyph |
| --ds-surface-overlay | — | Suggestion panel |
| --ds-layer-hover | — | Active suggestion row |
Spacing
| Token | Value | Used for |
|---|---|---|
| padding-left | Room for the magnifier | |
| panel offset | Gap to the suggestion panel |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Field corners |
Motion
| Token | Value | Used for |
|---|---|---|
| debounce | Request delay, never input delay |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Icon | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|
| Small | 32px | 0 28px 0 28px | 14px | 13px | — | — | — | Table toolbars and filter bars, where it narrows what is already visible. |
| Medium | 36px | 0 32px | 15px | 15px | 16rem | — | — | The default. App headers and page-level search. |
| Large | 48px | 0 44px | 18px | 16px | — | — | — | A dedicated search page, where the field is the subject of the screen. |
| Clear button | 20px | — | — | — | 20px | — | 44px on coarse pointers | Appears only when there is a query. |
| Suggestion panel | max 18rem | — | — | — | — | Matches the field | — | About 8 rows before it scrolls. Wider than the field reads as a different component. |
setQuery(v) // instant
debounce(() => fetch(v), 300)if (res.query !== currentQuery) return ← missingNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- The placeholder owes 4.5:1. It carries the field’s meaning here, which makes the usual "it is just a hint" excuse even weaker than normal.
- The magnifier owes 3:1 as a meaningful icon — it is what identifies the control.
- The clear button must reach 3:1 against the field fill. At 14px inside a low-contrast well it is easy to lose.
- The active suggestion row must be distinguishable from idle rows without relying on colour alone.
Keyboard
| / or ⌘K | Focuses the field from anywhere, when no other input has focus. Advertise it with a Kbd hint in the field. |
| ↓ | Moves into the suggestions. Focus stays in the input; aria-activedescendant moves the highlight. |
| Enter | Accepts the highlighted suggestion, or runs the typed query when nothing is highlighted. |
| Esc | Closes the suggestions on the first press, clears the query on the second. Two presses, two distinct outcomes. |
| Tab | Leaves the field and closes the panel without accepting anything. |
Screen readers
- Announce the count after typing settles: "3 of 48 services match". This is the only feedback that exists for a non-visual user.
- When suggestions open, announce how many: "6 suggestions available".
- A zero-result state must be announced, not just rendered. Silence is indistinguishable from a request that never fired.
Focus & touch
- Clearing returns focus to the field, not to the body — the user almost always wants to type a new query. Accepting a suggestion also returns focus to the field, with the query filled in.
- The clear button needs a 44px target, which usually means growing the field to 44px. Set enterKeyHint="search" so the on-screen keyboard shows a search key. Suggestions on mobile should render inline under the field rather than as a floating panel — the keyboard covers the lower half of the screen and a floating panel lands underneath it.
| Attribute | Applied to | Notes |
|---|---|---|
| type="search" | The field | Gives the searchbox role and the platform’s own clear affordance on some browsers. |
| aria-label | The field | Mandatory when there is no visible label. A placeholder is never an accessible name. |
| role="combobox" | The field | Only when suggestions exist, with aria-expanded, aria-controls and aria-activedescendant. |
| aria-live="polite" | The result count | Debounced to about 500ms after typing stops. Announcing per keystroke is unusable. |
| aria-busy | The results region | While the request is in flight, so the user knows the silence is loading rather than nothing. |
| aria-label | The clear button | "Clear search". It is a distinct control and needs its own name. |
Example usage
1import { SearchInput } from '@/ui/Input'23const [query, setQuery] = React.useState('')4const [results, setResults] = React.useState([])56// Debounce the REQUEST. The input value updates instantly, always.7const debounced = useDebounced(query, 300)89React.useEffect(() => {10 if (!debounced) return setResults(all)11 let cancelled = false12 search(debounced).then((r) => {13 // Without this guard, a slow response for "ap" overwrites the correct14 // results for "api-gateway" and nothing on screen explains why.15 if (!cancelled) setResults(r)16 })17 return () => { cancelled = true }18}, [debounced])1920<SearchInput21 value={query}22 placeholder="Search services…" // names the SCOPE23 aria-label="Search services" // a placeholder is not a name24 enterKeyHint="search"25 onChange={(e) => setQuery(e.target.value)}26 onClear={() => {27 setQuery('')28 setResults(all) // restore, do not just blank the box29 inputRef.current?.focus()30 }}31/>3233{/* The only feedback that the query did anything. */}34<p aria-live="polite" className="sr-only">35 {results.length} of {all.length} services match “{debounced}”36</p>Framework-free HTML
<div class="ds-search">
<svg class="ds-search__icon" aria-hidden="true">…</svg>
<input
type="search"
role="combobox"
aria-label="Search services"
aria-expanded="true"
aria-controls="search-suggestions"
aria-activedescendant="sug-2"
placeholder="Search services…"
enterkeyhint="search"
autocomplete="off"
/>
<!-- Only rendered when there is a query to clear. -->
<button type="button" class="ds-search__clear" aria-label="Clear search">
<svg aria-hidden="true">…</svg>
</button>
</div>
<div id="search-suggestions" role="listbox" aria-label="Suggestions">
<div id="sug-2" role="option" aria-selected="true">api-gateway</div>
</div>
<p class="sr-only" role="status" aria-live="polite">3 of 48 services match</p>CSS
.ds-search { position: relative; }
.ds-search input {
inline-size: 100%;
block-size: 36px;
padding-inline: 32px; /* magnifier leading, clear trailing */
border: 1px solid var(--ds-border-subtle);
border-radius: var(--radius-md);
background: var(--ds-surface-inset);
}
/* Leading, always. A trailing magnifier reads as a submit button. */
.ds-search__icon {
position: absolute;
inset-inline-start: 10px;
inset-block-start: 50%;
translate: 0 -50%;
color: var(--ds-fg-muted);
pointer-events: none;
}
/* The browser's own is inconsistent and unstyleable — draw our own. */
.ds-search input::-webkit-search-cancel-button { appearance: none; }
.ds-search__clear {
position: absolute;
inset-inline-end: 8px;
inset-block-start: 50%;
translate: 0 -50%;
inline-size: 20px;
block-size: 20px;
}
/* Matched to the field width, so the two read as one control. */
.ds-search__panel {
position: absolute;
inset-inline: 0;
inset-block-start: calc(100% + 6px);
max-block-size: 18rem;
overflow-y: auto;
border-radius: var(--radius-lg);
background: var(--ds-surface-overlay);
box-shadow: var(--shadow-e4);
}
@media (pointer: coarse) {
.ds-search input { block-size: 44px; }
.ds-search__clear { inline-size: 44px; block-size: 44px; }
/* The keyboard covers the lower half of the screen; a floating panel
lands underneath it. */
.ds-search__panel { position: static; box-shadow: none; }
}Component API
SearchInput
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Controlled. Update it on every keystroke — only the request is debounced. |
| onClear | () => void | — | Renders the clear button when a value is present. Must restore the full result set and refocus the field. |
| placeholder | string | — | Names the scope: "Search services…". Never a substitute for aria-label. |
| loading | boolean | false | Swaps the magnifier for a spinner while a request is in flight. |
| suffix | ReactNode | — | A shortcut hint when empty. Replaced by the clear button once there is a query. |
| size | 'sm' | 'md' | 'lg' | 'md' | Small filters a visible list; large is the subject of a search page. |
Professional tips
- Put the query in the URL. A search result that cannot be shared or bookmarked is half a feature, and back should return to the previous query rather than the previous page.
- Highlight the matched substring in results. It explains why each row is there and makes a fuzzy match feel intentional rather than random.
- Search across synonyms, not just the literal string. Half your users will type "delete" for what you named "remove".
- Cache the last few queries in memory. Backspacing through a query should not re-request every intermediate state.
- Trim whitespace before searching but not while typing — trimming as they type eats the space before the next word.
Performance
- Cancel in-flight requests with an AbortController when the query changes. Without it, a fast typist has six requests racing to render.
- Filter client-side under about 1,000 items. A round trip to filter a list already in memory is latency the user pays for nothing.
- Index once on mount rather than lowercasing every item on every keystroke — with a few thousand rows that difference is visible.
- Debounce the live-region announcement separately and more slowly than the fetch. Around 500ms after typing stops is right.
Common mistakes
- Debouncing the input value, so typed characters appear late and the field feels broken.
- No stale-response guard, so slow results for an earlier query overwrite correct ones.
- A placeholder with no aria-label, leaving the field unnamed for assistive tech.
- A permanent clear button that does nothing on an empty field.
- Clearing the text without restoring the results, leaving a filtered list with an empty search box.
- No result count, so nothing confirms the search ran.
- A trailing magnifier, which every user reads as a submit button.
Real-world recommendations
- Search is often the most-used feature in an internal tool and the least-instrumented. Log queries with zero results — that list is the highest-value backlog you have.
- Recent searches consistently outperform popular searches in an authenticated product. What this user did last week beats what everyone did yesterday.
- The "/" shortcut is a widespread convention now. Advertising it with a Kbd hint in the field costs nothing and moves keyboard adoption more than any documentation.
- A search box over fewer than about ten visible rows is chrome. Users scan ten rows faster than they can type three characters.