Chapter 22
Type-as-you-go search
A search that answers while you type looks like magic, but it's stagecraft: debounce, tokens that invalidate stale responses, a threshold below which the spinner must not appear. It all happens between one keystroke and the next, where the user isn't looking.
Type-as-you-go search — results that appear while you type, without pressing Enter — is one of the interactions that look simplest and are among the trickiest. From the outside it’s a single thing: I type, and the list updates. But between one keystroke and the next there’s a small invisible stagecraft, and that’s exactly this chapter’s question: what happens between every key pressed? The answer decides whether the experience is calm or nervous, honest or anxiety-inducing.
The risk isn’t that it won’t work — it almost always works, even done badly. The risk is noise: a request per letter, results that flicker and rewrite themselves, a spinner that appears and disappears on every breath, a slow response for “sear” overwriting the already-arrived one for “search”. These are all faults of stagecraft, not logic. And they’re solved with three tools that return in every demo: the debounce that waits for a pause, the token that invalidates stale responses, the threshold of decency below which no loader shows.
Throughout the chapter the data is static and lives inside the component; latency is simulated with setTimeout, and request cancellation with a monotonic token acting as an AbortController. No real network — but the stagecraft is exactly the one a real network would need. Five demos, five pieces of the craft: the timing, the highlighting, the grouping, the honest empty state, and keyboard navigation.
The timing between one key and the next
The first piece is the most hidden and the most important: when you query, and when you show that you’re querying. Get this wrong and any search turns noisy, however lovely the results.
It queries when your fingers pause, not on every key.
Type to search.
Search with debounce, token and threshold of decency
Type fast and watch what doesn’t happen: no request fires for every letter. The debounce (~250ms) waits for your fingers to pause a moment, then queries once. And the spinner appears only if the wait exceeds ~400ms — the “threshold of decency” from chapter 02: for a snappy response you see nothing, because a loader that lives 120ms is just flicker, “gratuitous unkindness”. Latency here is fake and deliberately variable, so sometimes the loader shows and sometimes it doesn’t: proof that the threshold is a decision, not an accident.
The subtlest fault is invisible to the naked eye: requests leave in order but don’t return in order. “sear” can answer after “search” and overwrite the right result with a stale one. The defense is a monotonic token: every new query increments it, and a response that comes back with a superseded token is ignored. It’s the poor man’s AbortController — the only rule that guarantees the screen always ends up showing the last thing you typed, not the last thing that returned from the network. The result count is announced via aria-live, so the search exists too for someone who can’t see the list change.
Showing why a result is there
Once the results arrive, there’s a second honesty question: why does this result match my query? Highlighting the slice of text that matched isn’t decoration — it’s the explanation of the result, made visible.
The highlighted parts say why each result is here.
Type to see the matches highlighted.
Match highlighting with semantic mark
Type “anat” and in the result the matching substring lights up. The right tag is <mark>, not a coloured <span>: <mark> carries meaning — “portion relevant to the current context” — and some screen readers announce it. Colour is the second channel, tag and weight are the first: someone who can’t tell yellow apart still reads that that part is marked. The match is also accent-insensitive — “citta” finds “città” — but the displayed text stays the real one, accents included: it highlights without rewriting.
There’s a security trap underneath. The highlight is never built by concatenating HTML strings around the query: that would be an injection waiting to happen, because the query is user text. You work on DOM nodes — split the string into match/non-match segments and create real <mark> and text nodes. The query becomes textContent, never markup. It’s the same discipline as the inputs chapter: convenience isn’t worth a hole, and the native way to build nodes is also the safe one.
A list that becomes a map
When results come from different sources — chapters, tags, people — a flat list mixes them and forces the eye to sort them itself. Grouping them by type turns the list into a map: the user understands where they’re looking before even reading the entries.
Results split by type: chapters, tags, people.
Type to search across chapters, tags and people.
Suggestions grouped by type, with headings
Search “a” and the results split into logical columns: Chapters, Tags, People, each with its heading. The order of the groups is fixed — the hierarchy mustn’t dance on every key — and empty groups disappear entirely: a heading with no entries below is an empty drawer that lies about having content. The sorting cuts cognitive load: the eye jumps to the type it cares about instead of reading an undifferentiated column.
On accessibility, the group heading is a sign, not a choice: it’s aria-hidden, and the group carries the type name via role=“group” + aria-label, so the screen reader announces “Chapters group” without turning the title into a fake selectable option. The entries are role=“option” inside a role=“listbox”. Here the listbox is presentational only — full keyboard navigation is the last demo’s job — but the semantic structure is already the right one, ready to carry the keyboard when needed.
The empty that isn’t a dead end
The “no results” state is the most fragile moment of a search: it’s easy to leave the user facing a wall. But almost always behind the wall there’s a door — a typo, an extra letter, a synonym.
Mistype by one letter: it offers the correction.
Type a chapter name.
No results that offers honest alternatives
Type “toogle” with the extra O: instead of a flat “nothing”, the panel offers “did you mean… toggle?”, and the correction is a real <button> — you reach it with the keyboard, press it with Enter, and the search restarts corrected. The alternative is found with edit distance (Levenshtein) between the query and each known entry: if something sits one or two edits away, it’s offered. It’s the difference between a search that slams the door and one that walks you to the handle.
But there’s an honesty line not to cross. If nothing is close enough, you don’t invent a suggestion just to fill the void: you stay honest all the way — “no results, try another word”. Offering a distant alternative only to avoid leaving the page empty would be a suggestion that lies, and it undermines trust in every later suggestion. The threshold grows with the query’s length (one error per ~4 letters, never beyond two): a “did you mean” earns its line only when it’s genuinely likely.
The search you drive from the keyboard
The last demo gathers the whole chapter into the pattern that makes search usable without a mouse: the full ARIA combobox. It’s not a power-user fancy — it’s what lets a screen reader announce “menu open, four options, option one of four highlighted”.
Keyboard: ↑ ↓ to move, Enter to choose, Esc to close.
Type to open the suggestions.
Full ARIA combobox, keyboard-navigable
Type a few letters and the listbox opens with the first relevant result already highlighted: so Enter picks the most likely thing at once, without even pressing ↓. From there the keyboard does it all — ↓/↑ move the highlight (cycling at the edges), Home/End jump to first and last, Enter selects, Esc closes. The heart of the pattern is that DOM focus stays in the input while the highlight travels: you keep typing, and aria-activedescendant tells the screen reader which option is active without moving focus out from under your fingers.
The ARIA contract is complete: the input is role=“combobox” with aria-expanded and aria-controls pointing at the role=“listbox”, every entry is role=“option” with a stable id. The highlight doesn’t rely on colour alone: the active option also has a different type weight, and the match is underlined, not just coloured. It’s the chapter’s synthesis: the invisible stagecraft of demo 22.a, the honest empty of 22.d, the safe highlighting of 22.b — all inside a control a person can use entirely with the keyboard and the screen reader, from start to finish.
Five demos, one idea: type-as-you-go search lives almost entirely in the space between one key and the next, where the user isn’t looking. The debounce that waits for a pause, the token that throws away superseded responses, the threshold that stays silent until the wait is real, the highlight that explains, the grouping that orients, the empty that offers, the keyboard that guides — none of these pieces shows on its own, and that’s exactly why they work. A good search doesn’t look clever: it just seems to understand, and it does so by working in silence while you think about what you’re searching for, not how to search for it.
The next chapter stays on inputs but changes object, tackling one of the most mistreated controls on the web: the calendar. How do you ask for a date without hating the browser? — between the native field nobody loves and the custom date-picker that breaks the keyboard, there’s an honest road, and it’s worth walking.