AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  ArrowUpRight,
  BookOpenText,
  Check,
  ChevronRight,
  CircleAlert,
  Globe,
  LoaderCircle,
  RefreshCcw,
  Search,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/web-search-card.json

Prompt

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 readState in 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 resultCount the 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.

On This Page