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.

The search door, live
Halo mark
Press the trigger and type. This is not a mockup — picking a result actually navigates this site. The bar at the top of this page mounts the same pattern with ⌘K wired up; the demo leaves the global shortcut to it so the two never fight.

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--sheet overlay 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) or onSelect (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 shortcutMatcher to 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

Mount the door in the app bartsx
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>
Build the corpus as PaletteItem[]tsx
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
}))
Seed ranking from the visit ledger (the designed next step)tsx
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.
  • /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).