Web Search Card
One web-search step inline in a conversation — the queries the model typed, the pages that came back, which ones it actually read and which ended up cited, with a data-driven reading shimmer and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/web-search-card.jsonPrompt
Build a React + TypeScript + Tailwind "WebSearchCard" component with zod and
lucide-react — one web-search STEP as it appears inline in an agent
conversation, not the source list under the finished answer.
Contract
- A zod schema in a sibling contract file is the single source of truth for ONE
step: { id, state, queries: Query[], results: Result[], error?, durationMs?,
engine? }.
- Query = { id, text, engine?, state?: "running"|"done"|"failed", note? }. `text`
is the string the model actually typed and is never re-worded for display — it
is the single most useful line on the card when an answer later turns out to be
wrong.
- Result = { id, title, url, domain, snippet?, faviconUrl?, queryIds?: string[],
citations?: number[], readState?: "listed"|"reading"|"read", publishedAt? }.
`queryIds` is an ARRAY because the same page routinely comes back for two of
three queries, and collapsing that would make the per-query counts lie.
`citations` are the [n] markers in the FINAL answer authored upstream, never
derived from an array index.
- The step state is FOUR values: "searching" | "reading" | "done" | "failed".
`reading` is a first-class phase, not a sub-case of searching: the result list
is already on screen and full text is being pulled out of specific pages.
A search that ran fine and found nothing is `done` with results: [] — never
`failed`.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
card's own render state. `status="error"` means the transcript row failed to
load; the SEARCH PROVIDER failing is state: "failed", and the two read
differently on screen.
- Props: search: Search | null; status; defaultOpen?; maxVisibleResults? (4);
snippetPreviewChars? (180); maxStackedFavicons? (5); activeCitation?: number |
null; onCitationClick?(n); onResultClick?(result, event); onRetry?;
emptyState?; errorMessage?; formatDuration?; label? ("Web search"); className
plus the rest of the element props, ref forwarded to the <article>.
- Every numeric prop is clamped (maxVisibleResults >= 1, snippetPreviewChars >=
60, maxStackedFavicons >= 1) so a 0 or a NaN can never render an empty card.
Behavior
- THE CARD OWNS NO CLOCK AND NO FETCH. The reading shimmer is attached to the
rows whose `readState` is "reading" — it is data, not a timer, so the same
props render identically on the server, in a replayed SSE stream and in a
screenshot fixture. `durationMs` is supplied; the component never measures.
- Disclosure defaults to OPEN while the step is in flight (searching / reading)
or has failed, and CLOSED once it is done: a step that is still narrating
itself earns the space, a finished one gets out of the way of the answer.
Entering "failed" force-opens the panel. Reaching "done" deliberately does NOT
close it — yanking content away from someone mid-read is worse than a tall
card. A new `search.id` resets disclosure, filters and expansions by adjusting
state during render (no effect, so no frame ever shows the previous step's
filters applied to new results).
- Collapsed, an in-flight step still shows a sweeping bar under the header: a
step that is running has to say so even when nobody opened it.
- Query chips are FILTERS. Clicking one shows only the results whose `queryIds`
contain it; clicking it again clears. The count on each chip is derived from
the results array, never read from a stored `resultCount`, because the chip has
to promise exactly what clicking it will leave on screen. A "Cited only (n)"
toggle is the second filter and only renders when something is cited at all.
- THREE ZERO STATES, three different sentences: `status="empty"` = the assistant
answered without searching; `state="done"` with no results = the search ran and
the web had nothing (worded and coloured as information, not as an error);
filtered-to-nothing = "No result matches this filter — the other 12 are still
there", with a Clear control. Collapsing any two of these into one message
sends the reader after the wrong problem.
- A half-failed step is not an empty one: `state="failed"` still lists whatever
results arrived before the provider quit, marks the dead queries on their own
chips with the reason, and prints the provider error in full — wrapped, never
inside a fixed-height box, because the tail of a quota error is what names the
cause.
- Truncation is always announced. The list draws `maxVisibleResults` rows and
ends in "Show all 24 results (20 more)"; a snippet is cut at
`snippetPreviewChars` with an inline More/Less toggle. Both numbers re-derive
from the FILTERED list, so a filter can never leave a stale remainder on
screen. Changing a filter resets the reveal to collapsed.
- The favicon stack in the header shows the first `maxStackedFavicons` DISTINCT
sites (first-seen order, each keeping the first favicon offered for it) and
ends in a "+N" tile. It is a density cue only: it is aria-hidden, because every
site in it is listed in full inside the panel and reading twelve unlabelled
images to a screen reader is noise.
- A favicon is allowed to fail: the <img> onError swaps in a monogram tile drawn
from the domain. Store the URL that broke, not a boolean, so a tile recycled
onto a different site retries the new image instead of inheriting the previous
one's failure — and no effect is needed to reset it. Never guess a third-party
favicon endpoint on the consumer's behalf; that hands every page their users
read to someone else's server.
- Result titles are real <a target="_blank" rel="noreferrer noopener"> links. The
card never calls preventDefault, so modified clicks stay the browser's
business; `onResultClick` is telemetry, not navigation.
- `citations` render as [n] tokens next to the title, becoming real buttons only
when `onCitationClick` is passed, and highlighting when they equal
`activeCitation` — the cross-highlight with the prose is a controlled value the
consumer owns, not scroll behaviour the card invents.
- Dates are formatted with a fixed locale AND a fixed time zone (Intl, UTC), and
an unparseable value is dropped rather than rendered as "Invalid Date".
Nothing reads Date.now(), so "3 days ago" is deliberately not on offer.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary + text-primary-foreground for a selected chip and an active
citation, border-primary/40 with bg-primary/5 for the reading strip,
text-destructive with bg-destructive/5 for the provider failure, bg-muted/40
for a row that was read. No hard-coded colours anywhere.
- Four phases are four SHAPES — spinner / open book / magnifier / alert — so they
survive a monochrome theme and a colour-blind reader, and one persistent
sr-only role="status" line spells the phase out in words ("Web search: Reading
nextjs.org, 12 results, 4 read, 4 cited"). It is mounted for the whole ready
branch so a phase CHANGE is announced from an element the screen reader is
already watching.
- Keyframes ship inside the component via a hoisted <style> element (no tailwind
config edit for the buyer). Every animation is decorative and guarded by
motion-reduce: the sweep disappears, the spinner stops, and the row still says
"reading…" in words.
- Long titles, hosts and queries use `wrap-anywhere` (overflow-wrap: anywhere)
plus `min-w-0`, NOT `break-words`: only the former lowers the element's
min-content width, which is what actually stops a 190-character title from
making the card wider than the message it sits in.
- The header is a real <button aria-expanded> whose panel is UNMOUNTED when
closed (a zero-height collapse leaves every chip and link reachable by Tab —
an invisible keyboard trap), and aria-controls is only set while open so it
never points at an id that is not in the document. Chips are aria-pressed
toggles inside a labelled role="group"; every control has a focus-visible ring.
- cn() merges the consumer's className into the root, which also spreads the
remaining element props and forwards its ref.
Customization levers
- Density: `maxVisibleResults` decides how many rows before the reveal (4 for a
chat bubble, 12 for a research panel, 1 for a compact log), `maxStackedFavicons`
the width of the header stack, `snippetPreviewChars` how much of a snippet is
drawn before More.
- Blocks you can drop: pass results without `snippet` and the rows become two
lines; without `citations` the [n] tokens and the "Cited only" filter vanish;
without `queryIds` the chips still show the queries but stop filtering; without
`faviconUrl` every tile is a monogram.
- Provenance depth: `readState` is optional — omit it entirely and the card
degrades to a plain "searched, here is what came back" list. Keep it and the
reader can tell "read 3 of 12" apart from "glanced at a result list".
- Wiring: `onCitationClick` + `activeCitation` connect the card to the [n]
markers in your prose (scroll, highlight, open a drawer — your call);
`onResultClick` is where analytics goes; `onRetry` + `errorMessage` own the
envelope failure; `emptyState` replaces the "answered without searching" body.
- Wording: `formatDuration` swaps the unit for tokens, credits or a cost figure;
`label` names the region ("Browsed the web", "Searched your workspace") and
flows straight into the announcement.
- Chrome: drop the duration or the favicon stack by not supplying `durationMs` /
`faviconUrl`; the header layout absorbs both without a config flag.Concepts
- Searching and reading are different phases — "searching" is waiting on a provider with nothing to show; "reading" is a list that already exists while full text is pulled out of two named pages. Narrating the second one ("Reading nextjs.org…") is what turns a twenty-second hang into visible progress, and it only works because the shimmer is bound to
readStatein the data rather than to a timer the card started. - Counts must promise what clicking delivers — every number on the card (per-chip counts, "4 cited", "Filtered to 3 of 12", "20 more") is derived from the results array at render time, never read from a
resultCountthe backend sent. A chip that says 5 and produces 3 rows reads as a broken filter, and the reader stops trusting the rest of the card with it. - Listed, read and cited are three different claims — twelve pages came back, four were opened, four ended up carrying an
[n]. A source list can only tell you the last one. Keeping all three visible is the difference between "the answer is grounded" and "the model saw a result list", which is exactly the question a reader is asking when they open this card. - Three zero states, three sentences — no search at all, a search that found nothing, and a filter that matched nothing are different problems with different next moves. Collapsing them into one "No results" line is the single most common way this component gets built wrong.
- Announced truncation, re-derived per filter — the reveal always names its remainder ("Show all 24 results (20 more)"), and both the visible slice and the remainder come from the FILTERED list, so a filter change can never leave a stale count behind. A silent cap is worse than no cap: it makes you debug the wrong thing.
- The favicon stack is a density cue, not content — it is aria-hidden, because every site in it is spelled out in the panel below, and a screen reader announcing twelve unlabelled images is noise. The "+N" tile is there so the stack's width is a constant no matter how wide the search went.
Indexing Progress
An ingest pipeline board — per-source parse → chunk → embed chips, a byte-weighted aggregate with a derived ETA, failures that keep the stage they died at behind a one-shot retry, and a completed summary.
Voice Orb
A voice-session orb — layered token gradients that breathe when idle, ripple to a 0..1 amplitude while listening, swirl while thinking and pulse while speaking.