Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
/

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.

EmptyNo clear button
With a queryClear appears

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.

3 of 48 services match “api”

No results

Name the query, offer the way out. A blank panel makes the user wonder whether the search ran at all.

No services match “zzz”.

Check the spelling, or clear the search to see all 48.

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.

Empty
/
Shortcut hint
Focus
With query
Loading
Disabled
Small
Large
api-gateway
Suggestion
failed deployments
Popular

Anatomy

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.

  1. 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.

  2. 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.

  3. 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.

  4. Shortcut hintKbd, trailing, when empty

    Swaps out for the clear button once typing starts. It is how anyone learns the shortcut exists.

  5. Debounce300ms on the request

    On the fetch only. The characters appear instantly; a debounced input feels broken within two keystrokes.

  6. Suggestion panel6px below, full width

    Matched to the field width so the two read as one control. Max 8 rows before it scrolls.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
padding-leftRoom for the magnifier
panel offsetGap to the suggestion panel

Radius

TokenValueUsed for
--radius-mdField corners

Motion

TokenValueUsed for
debounceRequest delay, never input delay

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingIconTypeMin widthMax widthTouch targetWhen to use
Small32px0 28px 0 28px14px13px———Table toolbars and filter bars, where it narrows what is already visible.
Medium36px0 32px15px15px16rem——The default. App headers and page-level search.
Large48px0 44px18px16px———A dedicated search page, where the field is the subject of the screen.
Clear button20px———20px—44px on coarse pointersAppears only when there is a query.
Suggestion panelmax 18rem————Matches the field—About 8 rows before it scrolls. Wider than the field reads as a different component.

Do

setQuery(v) // instant
debounce(() => fetch(v), 300)
Debounce the request, not the inputCharacters must appear the instant they are typed. A debounced value feels broken within two keystrokes, and users start pressing keys harder.
Name the scope in the placeholder"Search services…" sets expectations before the first query fails. "Search" makes the user find the boundary by hitting it.
3 of 48 services match “api”
Announce the result countIt is the only confirmation the query did anything. Without it a screen-reader user types and hears nothing at all.
Recent
api-gateway
Show recent searches on focusThe empty state is free real estate for teaching what is searchable. Recents also make the second search of a session almost instant.

Don't

Go
Do not require a submit pressLive filtering is the expectation now. If the query genuinely is expensive, debounce longer and show a loading state — do not make the user press a button to find out.
if (res.query !== currentQuery) return ← missing
Do not render a stale responseA slow request for "ap" landing after a fast one for "api-gateway" replaces correct results with wrong ones, and nothing on screen explains it.
Do not show a permanent clear buttonA × on an empty field is a control that does nothing. Users press it once to find out and then distrust it when it matters.
Search…
Do not put the magnifier at the endA trailing magnifier is a submit button in every other product the user has used. They will type, wait, and then press it.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.3.1Info and RelationshipsA2.1.1KeyboardA2.4.6Headings and LabelsAA4.1.3Status MessagesAA

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 ⌘KFocuses 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.
EnterAccepts the highlighted suggestion, or runs the typed query when nothing is highlighted.
EscCloses the suggestions on the first press, clears the query on the second. Two presses, two distinct outcomes.
TabLeaves 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.
AttributeApplied toNotes
type="search"The fieldGives the searchbox role and the platform’s own clear affordance on some browsers.
aria-labelThe fieldMandatory when there is no visible label. A placeholder is never an accessible name.
role="combobox"The fieldOnly when suggestions exist, with aria-expanded, aria-controls and aria-activedescendant.
aria-live="polite"The result countDebounced to about 500ms after typing stops. Announcing per keystroke is unusable.
aria-busyThe results regionWhile the request is in flight, so the user knows the silence is loading rather than nothing.
aria-labelThe clear button"Clear search". It is a distinct control and needs its own name.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
valuestring—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.
placeholderstring—Names the scope: "Search services…". Never a substitute for aria-label.
loadingbooleanfalseSwaps the magnifier for a spinner while a request is in flight.
suffixReactNode—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.

Notes

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.