Patterns
Search
One door, and it's a friendly one. Global search is the command palette — ⌘K from anywhere, or the search-shaped trigger in the app bar. Everything else that looks like search is filtering — narrowing a collection that's already on screen. This page is the recipe for the door: what it's made of, when to grab it versus a scoped filter, and how your navigation history feeds what it should surface.
Live demo
The real mechanism, mounted in a mock app bar. <ReferenceMiddle> composes <CommandPaletteTrigger> (the search-shaped chrome in the bar) with <CommandPalette> (the modal that owns the actual input) and binds the keyboard shortcut. The index is this site's own — every live page, grouped by section.
One door: the command palette
A shell gets exactly one global search door, and this is it. The trigger looks like a search input because that's what people scan for — but it's really a button; the palette it opens owns the real input. Three parts, one owner each:
- <CommandPaletteTrigger>
- The chrome in the app bar. Search icon + inert placeholder + shortcut hint, announced to assistive tech as "Search docs (⌘K)". Pressing anywhere on it opens the palette.
- <CommandPalette>
- The ⌘K modal — the canonical search door. A flat list of items filtered as you type, on a solid
card--sheetoverlay panel — opaque, with the deepest shadow in the set, so it clearly floats above the page (the frosted-glass version of this surface belongs to the proposed system). It dispatches the selected item to a navigation target or a custom action. - shortcut owner
- The composing pattern (ReferenceMiddle in a Reference shell) binds ⌘K / Ctrl+K at the document level. The trigger's hint is purely visual; the binding lives in exactly one place per shell.
- PaletteItem[]
- The corpus, supplied by the app — label, optional description and group, hidden match keywords, and either an href or an onSelect. If any item carries a group, results render under group headings.
Type-to-find on purpose: nothing shows up before you type, then the top 5 matches. The palette is the jump-to tool — browsing the whole map is the nav rail's job. The short result list is also what keeps the mobile sheet happy above the on-screen keyboard.
Scoped filtering vs global search
The dividing question is where the answer lives. Global search takes you somewhere else — it traverses a corpus and ends in navigation. Filtering keeps you right where you are — it narrows a collection that's already loaded on the page. They share an icon and a gesture, never a mechanism:
- global search
- The command palette. Cross-corpus, ends in navigation. Lives in the app-bar middle; one per shell.
- collection filter
- DataTable's global search and column filters — narrows the rows already on screen. The result count changes; the page does not.
- SearchField
- The form-bound search input primitive (type="search", search icon, clear button) for embedded search inside a view — a settings page's member list, a long picker.
- Selector typeahead
- Searchable option picking inside a form control. Choosing a value, not navigating to one.
In every register, search reveals the current state of the data — including emptiness. An empty result is information, not an error: the palette's "No matches." names the situation and waits; a filtered collection's empty state offers "clear search" as the next action.
Recents: the visit ledger
The NavHistory store keeps two separate records on purpose: the stack (the back/forward model, which truncates its forward branch when you navigate somewhere new) and the visit ledger — a monotonic count-and-recency map that never truncates when you branch. Folding them into one list is the classic mistake: it makes "have I seen this" lossy. The ledger is the first-party signal search ranking reads.
- stack
- entries + cursor. Drives the back/forward buttons and the recents menu in the app bar. Capped at 50.
- visit ledger
- A persistent
pathname → { count, lastVisited }map. A page you saw an hour ago stays "visited" even after you back up and go elsewhere. Capped at 200, evicting the least-recently-visited. - read API
getVisits()returns the full ledger for ranking;useHasVisited(pathname)is the cheap per-path boolean for "dim what I've already seen" styling.
Honest status: the ledger ships and the ranking doctrine is settled — deprioritize what you've already seen, relevance still dominates — but today's palette is pure type-to-find: it doesn't show a recents row before you type yet, and its substring match doesn't read the ledger yet. That seeding is the named next step, not a shipped behavior.
Keyboard model
The palette is keyboard-first; the pointer is the secondary path. Every binding below ships today:
- ⌘K / Ctrl+K
- Opens the palette from anywhere in the shell. Bound at the document level by the composing pattern; consumers can swap the matcher or disable it.
- Esc
- Closes — always on the first press. The listener runs in the capture phase so the browser's native clear-the-input behavior never eats the keystroke, and focus containment dismisses on blur so keyboard extensions (Vimium) can't strand it open.
- ↑ / ↓
- Move the highlight through the flat result list, across group boundaries.
- Home / End
- Jump to the first / last result.
- Enter
- Dispatches the highlighted item — navigation for href items, the custom action for onSelect items. The palette closes first.
- Tab / Shift+Tab
- Trapped inside the dialog. With one focusable control (the input) it is a no-op by design; future in-dialog controls inherit the trap.
Voice: the placeholder names the scope
The placeholder is system voice — quiet placeholder type on a flat surface (engraving is the proposed system's treatment; here, type is just type) — and it names the corpus: "Search docs", "Search rulebooks" — never a bare "Search". What the user types is user content, so it renders as plain, full-strength text. The same split holds in results: group headings are system voice; the labels the user is scanning are their content.
Accessibility
- The trigger is a real button. Focusable, announced as "Search docs (⌘K)". The visual placeholder is inert — there is exactly one real input, inside the palette, so assistive tech never meets two search boxes.
- Combobox semantics. role="dialog" aria-modal="true" on the surface; the input is a combobox controlling a listbox; the highlight is carried by aria-activedescendant + aria-selected, so focus never leaves the input while arrows move the selection.
- Focus restores. The element focused before the palette opened gets focus back on close — open-search-then-Esc returns you exactly where you were.
- States are announced. The "No matches." empty body is a role="status" live region; the pre-query hint is visible text, not placeholder-only signal.
- Not color-alone, not motion-dependent. The active option pairs its highlight with aria-selected and position; under reduced motion the palette simply appears — its little entrance collapses to a cut.
API
Four surfaces, smallest first. Most consumers only ever touch the last one.
- PaletteItem
- id · label · description? · group? · keywords? and exactly one of
href(navigation) oronSelect(custom action). Keywords are matched, never displayed. - CommandPalette
- items · open · onOpenChange (controlled) · onNavigate? (pass your router; defaults to location.assign) · placeholder? · emptyState? · onError? (telemetry for the internal boundary).
- CommandPaletteTrigger
- placeholder? · shortcutHint? plus native button props. The hint is cosmetic — binding the key is the consumer's job.
- ReferenceMiddle
- The composed pattern for Reference shells: trigger + palette + the ⌘K binding. items · placeholder? · shortcutHint? · onNavigate? · onError?, and
shortcutMatcherto swap the binding or null to disable it (this page's demo does exactly that). Self-isolates per ADR-0030 — a throw degrades to an inert trigger silhouette, not a collapsed bar.
Usage
import { AppBar, ReferenceMiddle } from '@halo-compliance/ui'
import { SEARCH_INDEX } from './search-index'
<AppBar.Middle>
<ReferenceMiddle
items={SEARCH_INDEX}
placeholder="Search docs"
onNavigate={(href) => router.navigate({ to: href })}
/>
</AppBar.Middle>import type { PaletteItem } from '@halo-compliance/ui'
const items: PaletteItem[] = pages.map((page) => ({
id: page.to,
label: page.title,
description: page.blurb,
group: page.section, // any group on any item => grouped headings
href: page.to,
keywords: 'synonyms tags aliases', // matched, never displayed
}))import { getVisits } from '@halo-compliance/ui'
// First-party ranking signal: deprioritise what the user has already
// seen — relevance still dominates. The ledger never truncates when
// the back/forward stack branches.
const visits = getVisits() // Record<pathname, { count, lastVisited }>
const rank = (item: PaletteItem) =>
baseRelevance(item) - (visits[item.href ?? '']?.count ? SEEN_PENALTY : 0)Gaps
- Substring match, not fuzzy. Matching is case-insensitive substring across label, description, and keywords. Typo tolerance and fuzzy ranking are open; keywords are today's tool for synonyms.
- No recents row yet. The visit ledger ships and persists, but the palette renders nothing before a query. Seeding the empty state with recent, relevant pages is the named next step.
- No match highlighting. Results don't yet emphasize the matched substring inside labels.
- No faceted composition. The recipe for composing search with structured filters (facets, scoping chips inside the palette) is unwritten — today the palette is navigation-only, and filtering belongs to the collection patterns.
Anti-patterns
- Don't open the palette to filter a list that's on screen. If the user can see the collection, narrowing it is the collection's job — DataTable search, a filter bar, a SearchField. The palette is for going somewhere, not for hiding rows.
- Don't mount a second search door. One shell, one palette, one ⌘K owner. A second global input splits the user's model of where search lives and races the shortcut binding.
- Don't make the trigger a real input. Two inputs (trigger + palette) means two focus targets, two autofill surfaces, and a screen-reader maze. The trigger is a button wearing a search field's silhouette — keep it that way.
- Don't ship a bare "Search" placeholder. An unscoped placeholder promises everything and explains nothing. Name the corpus, and keep the promise the name makes.
Related
- /app-bar — the bar that hosts the trigger; the middle slot is the app-bounded region search mounts into.
- /nav-history — the store behind recents: the back/forward stack and the visit ledger that seeds search ranking.
- /data-table — collection-scoped filtering: global search over loaded rows, the other register of "search".
- /selector — searchable option picking (typeahead) inside a form control.
- /field-patterns — SearchField, the form-bound embedded search input primitive.
- /modal — the overlay mechanics the palette's sheet shares (scrim, focus trap, return-focus).