SearchSuggest
A search field with a typeahead suggestions popup on Base UI Autocomplete — recents, grouped suggestions, match highlighting, action rows, async states and a full-screen mobile view.
pnpm add @tessinaui/uiWhen to use
- A search field that helps finish the query: recents, popular searches, typeahead, grouped suggestions by entity type.
- Free-form text stays free-form. The user can always search for exactly what they typed; a suggestion only fills the field and nothing is committed as a value.
- Recents before anything is typed. Set
openOnFocusand let people remove a stale row withonRemove. - Next steps beside the results: "See all results" or "Ask AI" as
SearchSuggest.Actionrows, which never fireonSearch. - Remote results with honest loading, error and empty states: pass
filter={null}and swapitemsyourself. - A placeholder that says what can be searched ("Search docs…"), not a bare "Search".
When not to use
- When the field needs no popup. Use Search.
Recents shown as page content under the field are a Search composition
(
search-recent). - To pick a value from a list (a country, an assignee). Use Combobox, which commits a selection; SearchSuggest never does.
- For a ⌘K overlay of commands, navigation and rich result rows. Use Command. Rows with title, description, meta and badges anatomy belong there.
- For facet tokens inside the field ("in:Products ×"). Use Tokenizer.
- When the set is small and fits in one view. Show the options instead of asking people to search for them.
Installation
pnpm add @tessinaui/uiUsage
import { SearchSuggest } from "@tessinaui/ui/search-suggest";<SearchSuggest.Root items={pages} onSearch={(query, { source }) => runSearch(query)}>
<SearchSuggest.Input searchLabel="Search docs" placeholder="Search docs…" />
<SearchSuggest.Popup>
<SearchSuggest.List>
<SearchSuggest.Collection>
{(page: string) => (
<SearchSuggest.Item key={page} value={page}>
<SearchSuggest.Highlight text={page} />
</SearchSuggest.Item>
)}
</SearchSuggest.Collection>
<SearchSuggest.Empty />
</SearchSuggest.List>
</SearchSuggest.Popup>
</SearchSuggest.Root>The field is the house Search: every Search prop
(variant, intent, tone, label, clear, submit, shortcut, cancel) works on
SearchSuggest.Input. Base UI's Autocomplete input sits inside it, so the text
stays free-form — a suggestion only fills the field. Nothing is committed as a
value.
items takes a flat array or sections ({ items: [...] }[]). Typing narrows
them with a locale-aware "contains" match; function children on List,
Collection and Group render what is left. For remote results pass
filter={null} and swap items yourself.
onSearch(query, { source }) fires when the user asks for results: "input"
for Enter with nothing highlighted or the submit button, "suggestion" when a
suggestion is chosen. Action rows never fire it.
Examples
Default
Type to narrow, arrow to highlight, Enter to choose. The field announces the result count through its always-mounted status region.
Grouped suggestions
Sections by entity type, each a role="group" named by its label. Rows take
leadingIcon or leadingAvatar and a description line that stays out of the
accessible name. positionerProps lets the panel grow wider than the field —
still capped by the space available.
Recent searches
openOnFocus shows recents before anything is typed. onRemove makes a row
removable — Shift+Delete (or Shift+Backspace) on the highlighted row, or the
×. fillable adds ↖, which puts the suggestion in the field without searching.
"Clear recent searches" is an action row with keepOpen.
Match highlighting
match (default) tints what was typed. inverse keeps the typed part muted and
gives the completion full ink — the style most shopping apps use for query
suggestions. Neither changes weight.
View all and Ask AI
SearchSuggest.Action rows do something with the query — see all results, ask
AI. They sit in the arrow-key order, never fire onSearch, never rewrite the
field, and are not counted as results. Children can be a function of the query.
Async, loading and error
Remote results: filter={null} and the results as items. Loading marks the
list busy, shows the field's spinner and announces "Searching…"; Error
announces its label. Put Retry in an action row with keepOpen, so it is
keyboard-reachable and the panel stays up. This example fails its first lookup
on purpose.
No results
Empty shows only when no suggestion is left, echoes the query, and pairs with
a recovery action — the reason and the way forward.
Trending
Before anything is typed, offer what is popular; typing swaps in query
suggestions (here with inverse highlighting).
Footer, keyboard hints and shortcut
Footer sits below the listbox, outside it, so it may hold links and buttons.
KeyboardHints renders ↑↓ / ↵ / esc and hides wherever touch may be the
pointer. showShortcut on the input binds ⌘K / Ctrl+K.
Mobile: full-screen view
Below md the default mobilePresentation="fullscreen" turns the open field
into a full-screen search view — field and Cancel at the top, the list filling
the rest, the page behind locked. "dropdown" keeps the anchored panel. Narrow
the window below 768px to compare.
Locations
A static "Current location" row before the collection, places with a context
line ("Airport in Paris") and a trailing kind. itemToStringValue names object
items.
Attached panel
attached joins the panel to the field while open. Use it with rounded
none–lg; a pill has no straight edge to share.
On-color
Inside a coloured Surface the field inherits tone="on-color": the well,
ink, placeholder and focus ring take the plate's ink. The suggestion panel and
the phone's full-screen view are page surfaces, so their rows and field keep
the page's inks whatever plate the field sits on.
RTL
dir on the root mirrors the field and is restated on the portalled panel, so rows put their icon on the right and trailing text on the left, and the fill arrow flips to point back at the field. Leave dir unset and nothing is stamped: the field and panel follow the page.
API Reference
SearchSuggest.Root
| Prop | Type | Default | Description |
|---|---|---|---|
items | T[] | { items: T[] }[] | — | Suggestions, flat or in sections. Filtered by the query unless filter={null} |
filteredItems | same | — | Already-filtered suggestions; skips filtering and limit |
filter | (item, query, itemToString) => boolean | null | contains | Custom match, or null for none |
limit | number | — | Most suggestions rendered across all sections |
itemToStringValue | (item: T) => string | label/value/String | Text for object items — what a chosen suggestion writes into the field |
value / defaultValue / onValueChange | string / string / (value, { reason }) => void | — / "" / — | The query text. reason is Base UI's (input-change, item-press, …) or fill |
open / defaultOpen / onOpenChange | boolean / boolean / (open, { reason }) => void | — / false / — | Popup state |
openOnFocus | boolean | false | Open on focus with nothing typed; clearing keeps it open |
onSearch | (query, { source: "input" | "suggestion" }) => void | — | The user asked for results |
mode | "list" | "both" | "inline" | "none" | "list" | Base UI completion: both also inline-completes the highlighted suggestion |
autoHighlight / keepHighlight / highlightItemOnHover / loopFocus | — | Base UI defaults | Highlight behaviour |
statusText | string | automatic | Override the announcement ("Searching…", "N results", "No results for “q”") |
size / rounded | Search scale | "md" / "full" | Shared by the field and the panel |
mobilePresentation | "fullscreen" | "dropdown" | "fullscreen" | Below md |
attached | boolean | false | Join the panel to the field |
disabled / readOnly / required / name / form | — | — | Field state and form participation |
dir | "ltr" | "rtl" | — | Direction |
Other attributes and className land on the root <div>.
SearchSuggest.Input
Every Search prop except value,
defaultValue, onValueChange, onSearch, size, rounded, disabled,
statusText and renderInput, which the Root owns. loading defaults to
"a Loading part is mounted"; showCancelButton defaults to "the full-screen
view is showing". The ref is the input.
SearchSuggest.Popup / List
Popup takes positionerProps (side, align, offsets, style for width) and
portalProps. List is the listbox and the scroller; its function children
map the filtered items. Default aria-label "Suggestions".
SearchSuggest.Group / GroupLabel / Collection / Separator
Group items hands a section's filtered items to a Collection inside it.
Collection renders one element per item — of its Group, else of the Root.
GroupLabel names the group and cannot hold a control.
SearchSuggest.Item
| Prop | Type | Default | Description |
|---|---|---|---|
value | T | — | The suggestion |
leadingIcon / leadingAvatar | ReactNode | — | Leading visual |
description | ReactNode | — | Second line; the option's description, not its name |
trailingText | ReactNode | — | Trailing muted meta; outside the name |
onRemove / removeLabel | () => void / string | — / "Remove" | Removable row: Shift+Delete / Shift+Backspace, or the pointer × |
fillable / fillLabel | boolean / string | false / "Fill search" | Pointer ↖ writes the suggestion into the field without searching |
SearchSuggest.Action
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | (query) => ReactNode | — | Row label |
onAction | (query: string) => void | — | Click or Enter |
keepOpen | boolean | false | Leave the popup open |
leadingIcon / trailingText | ReactNode | — | Row decoration |
SearchSuggest.Highlight
text · query (defaults to the current query) · mode="match" | "inverse".
SearchSuggest.Empty / Loading / Error
Empty — default copy "No results for “q”" / "No suggestions". Loading —
label ("Searching…"), variant="spinner" | "skeleton", rows. Error —
label ("Couldn’t load suggestions").
SearchSuggest.Footer / KeyboardHints
Footer is a plain row below the list. KeyboardHints takes navigateLabel,
selectLabel, closeLabel.
Keyboard Interactions
| Key | Action |
|---|---|
| Typing | Narrows suggestions; opens the popup |
↓ / ↑ | Opens the popup / moves the highlight; focus stays in the field |
Alt+↓ | Opens the popup without moving the highlight |
Enter | Chooses the highlighted row; with nothing highlighted, searches the typed query |
Escape | Closes the popup and keeps the query; with the popup closed, clears the field |
Shift+Delete / Shift+Backspace | Removes the highlighted row when it has onRemove |
Home / End | Move the caret in the field |
⌘K / Ctrl+K | Focuses the field (showShortcut) |
Tab | Leaves the field; the popup closes |
Accessibility
VoiceOver + Safari: passed 2026-09-14. Focus reads the placeholder and the hint, "Type, arrow down, press Enter."; typing announces "20 suggestions"; Down reads "Accordion (1 of 20)"; Escape reads "collapsed". NVDA and JAWS are untested.
- The input is a
role="combobox"inside the Search landmark, witharia-expandedalways present,aria-autocomplete="list",aria-activedescendantfor the highlight andautocomplete="off".aria-controlspoints at the listbox only while it is open. - Result counts are announced from the field, not the popup: Search's status region is always in the DOM and empty until there is something to say. A region inside the popup would arrive already full when it opens, and a region arriving full is not announced.
- The listbox owns only options and groups. Empty and Error are disabled
options; Loading marks the list
aria-busy. Remove and fill are pointer affordances with keyboard equivalents, never buttons inside an option. - While the popup is open Base UI hides everything outside the field input and the popup from assistive tech — Search's clear, submit and Cancel buttons included. Screen-reader users close it with Escape or the "Dismiss" control Base UI places before the field and after the list.
- Rows keep the 44px touch floor; the × and ↖ affordances carry their own 44px hit areas on touch.
- Highlighting marks with a tint or ink, never weight, and the option's name is its unbroken label text.
- Known limits: iOS VoiceOver and TalkBack ignore
aria-activedescendant— every row still works by tap. The pointer × has no swipe equivalent; offer "Clear recent searches" as the reachable path.
Agent notes (AI-first)
data-slot: search-suggest-root · search-suggest-popup ·
search-suggest-list · search-suggest-group · search-suggest-group-label ·
search-suggest-separator · search-suggest-item ·
search-suggest-item-trailing · search-suggest-item-fill ·
search-suggest-item-remove · search-suggest-action ·
search-suggest-highlight · search-suggest-empty · search-suggest-loading ·
search-suggest-error · search-suggest-footer ·
search-suggest-keyboard-hints, plus every Search slot on the field. The root
stamps data-component="search-suggest", data-open and data-takeover.
States an agent can read: [data-slot="search-status"] text (count, loading,
error), aria-expanded on the input, aria-busy on the list.
// Copy-paste: remote suggestions with loading, error and a see-all row
<SearchSuggest.Root items={results} filter={null} value={query} onValueChange={setQuery} onSearch={runSearch}>
<SearchSuggest.Input searchLabel="Search orders" placeholder="Search orders…" />
<SearchSuggest.Popup>
<SearchSuggest.List>
{isPending && <SearchSuggest.Loading />}
{error && <SearchSuggest.Error />}
<SearchSuggest.Collection>
{(order: Order) => (
<SearchSuggest.Item key={order.id} value={order} description={order.customer}>
<SearchSuggest.Highlight text={order.number} />
</SearchSuggest.Item>
)}
</SearchSuggest.Collection>
<SearchSuggest.Empty />
<SearchSuggest.Action onAction={openAllResults}>{(q) => `See all results for “${q}”`}</SearchSuggest.Action>
</SearchSuggest.List>
</SearchSuggest.Popup>
</SearchSuggest.Root>Do: keep rows to one line plus an optional description · put actions in
SearchSuggest.Action · give object items itemToStringValue when they have no
label.
Don't: put buttons or links inside an item · use it to pick a form value (that
is Combobox) · build result cards in the popup (that is Command).