Search Results
A four-state search results page — query echo, keyword highlighting, faceted filters whose counts are the real post-click result count, and pagination that snaps back to page 1 on every filter change.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/search-results.jsonPrompt
Build a React + TypeScript + Tailwind "SearchResults" page block
(lucide-react glyphs, zod, and a SearchHighlight text component that marks
matches structurally).
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
query: string; // echoed and used as the highlight terms
queriedAt: ISO datetime; // the anchor for the recency facet
items: { id, title, href, snippet, breadcrumb: string[],
type: string, tags: string[], updatedAt: ISO datetime }[];
suggestions?: string[] } // "did you mean" alternates
- `snippet` is PLAIN TEXT. It is rendered as text nodes and highlighted with
real <mark> elements, never with dangerouslySetInnerHTML, so a snippet
containing "<img src=x onerror=…>" is shown as characters and cannot become
a node. Strip server-side <em> markup before it reaches this contract.
- `queriedAt` is data, not Date.now(): the recency buckets must be the same on
the server, in the client and in tomorrow's screenshot.
- `href` is refined at parse time to reject dead anchors ("#" and friends).
- Three whole-payload refines, each with its own existence guard because zod
runs every refine even after an earlier one failed: ready implies at least
one item, empty implies zero items, ids are unique. A refine that
dereferences items[0] without guarding turns safeParse into a thrown
TypeError on a ragged payload.
- Component props = z.infer of the schema plus pageSize? (default 5, clamped
to >= 1), locale? (default "en-US"), onRetry?, onSuggest?, className.
Facet selection and the current page are internal state — the host ships an
ordered result set, the block owns the narrowing.
Behavior
- Four first-class branches: loading (sidebar + result skeletons mirroring the
real anatomy), error (message + Try again only when onRetry exists), empty,
ready. `ready` with zero items is rendered as the empty state, so a
contract-breaking payload degrades instead of showing an empty page.
- Facet semantics, stated once and obeyed everywhere: OR inside a dimension,
AND across dimensions. Type and tags are multi-select checkboxes, recency is
single-select radios (its ranges are nested, so multi-select is meaningless)
with "Any time" as an explicit value. Options are DERIVED from the items in
first-appearance order, so the host orders the data to order the facets.
- HONEST COUNTS — the whole point: the number beside an option is
|results after that click|, computed by applying the toggle to the current
facet state and running the same predicate the list runs. Not a static
number, not a count that ignores the other dimensions. A zero therefore
means "clicking me leaves nothing", which is worth showing rather than
hiding: the click is never a dead end because the no-match panel offers the
way back. Screen readers get the meaning too, with wording that follows the
control: "Guide, 4 results if selected" / "… if removed" for the checkboxes,
"Any time, 14 results shown now" for the radio that is already on (clicking
it changes nothing, so "if removed" would be a lie).
- Every filter change resets to page 1. A filtered set is a different result
set; keeping page 4 is how users land on a blank page. The page number is
also clamped during render, so a shrinking result set can never point past
the end.
- Stale-selection guard: options that no longer exist in a new payload drop
out of the active selection during render, otherwise the block filters on a
value the user can neither see nor unselect.
- Highlighting: query terms are split on whitespace and matched
case-insensitively in the title and the snippet. Overlapping and adjacent
hits merge into one <mark> (two touching marks are read out twice by a
screen reader). The query is escaped before it reaches a RegExp, so "a.b"
cannot match "axb" and a lone "(" cannot throw.
- Two zero states with different copy and different exits:
* status === "empty" -> "No results for <query>", spelling/synonym advice
and, when suggestions and onSuggest are both present, "did you mean"
chips. No Clear-filters button, because no filter is hiding anything.
* ready + facets narrow to 0 -> "No results match these filters",
restating the total that is still there, plus Clear all filters.
- Counts are announced, not spammed: one always-mounted role=status region is
written imperatively 600ms after the last change, so a host that re-renders
on every keystroke does not narrate every keystroke. Mount itself never
announces.
- Pagination: results/page slice with a sliding page window (first, last, a
five-page run around the current page, "…" only when it hides more than one
page). Prev/Next at the edges use aria-disabled, not the native disabled
attribute, which would blur the button the moment you reach the last page.
- Nothing is silently capped: every item is reachable through the pages, every
facet option is listed, long titles and breadcrumbs wrap instead of clipping.
- Time rendering degrades instead of lying: each row shows "Updated 2 days ago"
measured from queriedAt; if queriedAt is unparsable the recency facet is not
rendered at all (a control that cannot filter honestly) and rows fall back to
an absolute UTC date; if the row's own updatedAt is unparsable it says so and
stays out of every time window instead of pretending to be recent.
- Numeric props are clamped: pageSize 0 / NaN / negative becomes 1, so a bad
prop cannot produce Infinity pages or an empty list.
Rendering & styling
- Semantic tokens only: bg-card panels, border / border-dashed for the two
zero states, text-muted-foreground for meta, bg-primary for the current page
and the active-filter badge, accent-primary on the native checkboxes and
radios. No hex, no rgb().
- Results are an <ol> (relevance order is meaningful) with start={offset + 1},
so page 2 counts from 6. Each facet dimension is a <fieldset> with a
<legend>; the count sits inside the <label> so it is part of the option's
accessible name.
- Layout is a single-column stack that becomes a
[minmax(0,15rem) minmax(0,1fr)] grid at lg. Below lg the facets live behind
an aria-expanded toggle and are display:none when closed — a visually hidden
panel would still be reachable by Tab.
- Overflow discipline: min-w-0 on every flex/grid child that holds text plus
wrap-anywhere on titles, breadcrumbs and facet labels. A 60-character URL
segment wraps instead of pushing the page sideways; fieldsets get min-w-0
because their default min-inline-size is min-content.
- Motion: only the skeleton pulses, and it stops under motion-reduce; every
transition is transition-colors and is disabled under motion-reduce without
losing any function. focus-visible:ring-2 ring-ring on every control.
Customization levers
- Add a fourth facet (author, product area, language) by copying the
derive-options -> toggle -> countAfter trio; the counts, the reset-to-page-1
and both zero states pick it up with no further changes.
- Facet semantics: make a dimension single-select by replacing toggleValue
with a replace, or make recency multi-select if your ranges stop nesting —
the counts stay honest either way because they are computed from the same
predicate.
- Server-side paging: replace `filtered.slice()` with a fetch keyed by
(facets, page) and keep the same UI; the contract already carries the full
ordered set for the client-side default.
- Highlighting: pass an explicit term array (stems, synonyms) instead of the
raw query, or switch the mode to "substring" for phrase search.
- Density: pageSize is a prop; drop the breadcrumb line or the tag chips for a
tighter row; move the type pill next to the title for a card-like result.
- Copy: the advice list, the two zero-state paragraphs and the "Each number is
the result count after that click" hint are single literals meant to be
swapped for your voice or an i18n lookup.
- Announcement timing: ANNOUNCE_DELAY_MS trades narration latency for calm; a
block that never re-renders per keystroke can drop it to 0.Concepts
- Honest facet counts — the number next to an option is produced by applying that click to the current filter state and running the same predicate the result list runs, so it is a promise about the next click rather than a decorative badge. A count that ignores the other dimensions (or, worse, a hardcoded one) is the most common way a search page lies to its user.
- OR inside, AND across — two selected types widen the set, a selected tag on top of them narrows it. Stating the rule once and computing counts from that same rule is what keeps the numbers and the list from drifting apart.
- Structural highlighting — matches become real
<mark>elements around text nodes, never an HTML string, so a snippet containing markup is displayed rather than parsed; overlapping and touching hits merge into one mark so a screen reader does not hear the same run twice. - Filtering resets pagination — a new filter combination is a new result set; keeping the old page number is how a user ends up staring at an empty page 4. The page index is additionally clamped during render, so a shrinking set can never point past the end.
- Two zero states — "this query found nothing" and "your filters hid everything" look identical if you reuse one panel. They get different copy and different exits: rewrite advice on one side, a Clear all filters button on the other.
- Recency anchored to data — the time buckets are measured from
queriedAtin the payload instead ofDate.now(), so the same response renders the same buckets on the server, in the browser, and in a screenshot taken a week later. - Calm live region — the result count is announced from one always-mounted
role="status"element written 600ms after the last change, so a host that re-renders on every keystroke narrates the outcome once instead of narrating the typing.
Empty Dashboard
A first-run dashboard page — welcome copy, a setup checklist that counts done and skipped separately, a labelled skeleton of the dashboard-to-be, and a confirmed sample-data escape hatch.
Checkout Form
A one-page checkout — contact, delivery address, payment method and a summary whose tax, delivery and fees are derived from the form in the same render, so the money can never lag the address.