Finding one thing among many, and narrowing a list you are already looking at.
Part of the Konjo design language. The laws in the spine apply here unless this chapter contradicts them.
Twenty files in src/ render a search input and none of them share a component. This chapter
is what they should have agreed on.
Search or filter — they are different
- Search takes a query and returns matches from a set the person cannot see all of.
- Filter narrows a set that is already on screen.
A screen can have both. It should never use one to do the other's job: a search field that only filters ten visible rows is friction, and a filter chip row standing in for search across four thousand records is a dead end.
When a list gets search
- Under ~15 items: no search. The list is the interface.
- 15–60: filter chips, if there is a meaningful axis. Otherwise nothing.
- Over ~60, or unbounded: search, and it goes first — above the filters, above the content.
The field
[ ⌕ Search students… ✕ ] h44 · radius.input · 1px border.control
( Brown belt ×) ( Needs waiver ×) chips wrap, h32, never scroll sideways
border.control, not a fill. A search field with only asurface.tagbackground is 1.017:1 on the page and is not a control anyone can see.- The placeholder says what is searched: "Search students…", not "Search". Never
text.quaternary— it is not a text colour. - A leading
inlinemagnifier, and a clear affordance once there is a query, in a 44pt target. returnKeyType="search", and searching does not dismiss the field.- Never autofocus on arriving at a tab. The keyboard covering the content someone came to browse is the most hostile thing a list screen can do. Autofocus only on a screen whose sole purpose is search.
Behaviour
- Search as they type, debounced. No search button.
- Below ~200 local items, filter locally and show results instantly. Do not round-trip a server for something already in memory.
- The previous results stay visible while the next ones load. Blanking the list on every keystroke makes a fast search feel broken.
- Never reorder results while someone is still typing in a way that moves the row under their thumb.
Filter chips
- Chips wrap; they never scroll horizontally. A filter you cannot see is a filter you will forget is on — and horizontal scrolling hides exactly the ones at the end.
- 32pt tall,
radius.pill,surface.cardwith a 1pxborder.control. Selected fillssurface.heroonsurface.cardwithtext.onHero— that is a control state, not a loud block, so it does not spend the screen's budget. - An active chip carries its own removal (
×) andaccessibilityState={{ selected }}. - More than two active filters get a "Clear all", as a text link.
- The count of what survived is stated: "14 of 132". Without it, an over-filtered list is indistinguishable from an empty one.
Empty results
The two empties are different and conflating them is the classic error.
| What it means | What it shows | |
|---|---|---|
| Empty search | The query matched nothing | Keep the field and the chips on screen. "No students match 'torez'." Offer the nearest real action: clear the query, or clear a filter that is doing the excluding. |
| Empty collection | There is nothing here at all | Replace the index entirely with the empty state — and remove the search field, because there is nothing to search. |
If filters are the reason, say which filter: "No students match 'ana' with Needs waiver on." A person who cannot see why nothing matched assumes the app is broken.
Accessibility
accessibilityRole="search"on the field.- Result counts announced with
accessibilityLiveRegion="polite"— a sighted user sees the list change, a screen-reader user gets silence otherwise. - Chips announce their selected state; a chip that only looks selected is invisible to half its users.
Never
- Autofocus on a tab home.
- A search field that blanks its results on every keystroke.
- Horizontally scrolling filter chips.
- An empty search that hides the field that caused it.
- Filters with no count and no clear.
- A search input styled as a filled block with no border.