Answer Sources
The numbered favicon cards behind one AI answer — a scrolling strip or an expandable list, the retrieved excerpt one hover or one Tab away, dead sources that refuse to link, and four data states.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/answer-sources.jsonPrompt
Build a React + TypeScript + Tailwind "AnswerSources" component with zod and
lucide-react, reusing a shadcn/Radix Tooltip for the excerpt preview.
Contract
- A zod schema (`answerSourcesItemSchema` in a sibling contract file) is the
single source of truth for ONE source: { id, n, title, url, domain, snippet?,
publishedAt?, faviconUrl?, dead? }.
- `n` is the citation NUMBER, authored by whatever produced the answer, and it is
the same number the inline [n] markers in the prose carry. It is deliberately
NOT the array index: a source cited three times keeps one number, and
re-ordering or filtering this list for display must never renumber the answer.
- `publishedAt` is ISO 8601 — a plain date ("2026-05-04") or a full instant. It
is formatted with a FIXED locale and timeZone: "UTC" so the server, the browser
and a screenshot fixture print the same string; an unparseable value is dropped
instead of rendering "Invalid Date".
- `faviconUrl` is supplied by the caller. The component never guesses a
third-party favicon endpoint (that would hand every source your users read to
someone else's server) — with no URL it draws a monogram from the domain.
- `dead` marks a link that no longer resolves. The source is still SHOWN — the
answer was grounded in it — but it stops being a link.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
list's own state, independent of any single source. "empty" is a real answer
with no grounding (the model answered from its parameters); "error" is the
retrieval sidecar failing while the answer itself rendered fine. Both read
differently on screen, and neither is an excuse to render nothing.
- Props: status; items: readonly Source[]; variant?: "row" | "list" ("row");
maxVisible? (4, clamped >= 1); defaultExpanded? (false); activeN?; preview?
(true); delayDuration? (250); target? ("_blank"); onSelect?(source, event);
onRetry?; formatDate?(iso) => string | null; heading? ("Sources", null hides
it); emptyState?; errorMessage?; label? ("Sources"); loadingCount? (4, clamped
>= 1); className plus the rest of the element props, ref forwarded to the
<section>.
Behavior
- TWO SHAPES, ONE DATA SET. `variant="row"` is the horizontal strip that sits
under an answer: fixed-width cards, `overflow-x-auto`, every card focusable so
a keyboard reaches the far end by tabbing and the browser scrolls for it.
`variant="list"` is the vertical, excerpt-carrying list for a side panel. The
numbering, the states and the callbacks are identical in both.
- OVERFLOW IS ALWAYS EXPLICIT. Beyond `maxVisible` the strip ends in a "+N more"
tile and the header grows a "Show all N" toggle; expanding the strip reflows it
from a scrolling track into a wrapping auto-fit grid so every source is fully
visible at once. Nothing is ever silently cut — a reader who cannot tell that
four sources are missing will not go looking for them.
- CROSS-HIGHLIGHT WITH THE PROSE. `activeN` highlights the matching card and
scrolls it into view with block/inline: "nearest" (the strip scrolls, the page
does not jump). Wire it to the inline [n] your reader is hovering. If that
source is currently collapsed away, the component EXPANDS FIRST — and the
expansion is done by adjusting state during render, not in an effect, because
an effect would paint one frame with the target still unmounted and then scroll
to a node that isn't in the document. `scrollIntoView` on a missing node fails
silently, which is the worst kind of failure.
- Under prefers-reduced-motion the scroll passes behavior: "auto" instead of
"smooth". This is the one place a `motion-reduce:` utility cannot reach,
because the value is a JS argument, not a class — so read the media query with
matchMedia, subscribe to changes, and remove the listener on unmount. Every
other transition is guarded with `motion-reduce:transition-none`.
- A DEAD SOURCE IS NOT A LINK. It renders as a focusable <button aria-disabled>
with a struck-through title and a crossed-out favicon, not an <a href> that
drops the reader on a 404. It stays in the tab order so its excerpt (and the
reason it is inert) is still reachable, and it becomes genuinely clickable only
when `onSelect` is passed — that is your archive lookup, not a navigation.
- FAVICONS ARE ALLOWED TO FAIL. The <img> has an onError that falls back to a
monogram tile, and the failure is stored as the URL THAT BROKE rather than a
boolean, so a card recycled onto another source retries the new image instead
of inheriting the previous one's failure. No effect is needed to reset it.
- `onSelect` intercepts only a PLAIN left click (preventDefault, then your
handler). ⌘/Ctrl/Shift/Alt and middle clicks fall through to the browser, so
"open in a new tab" never stops working. Without `onSelect` the card is an
ordinary link with rel="noreferrer" when target is _blank.
- The excerpt reveal is a Tooltip: hover AND keyboard focus, Escape to dismiss,
portalled so it escapes a chat bubble's overflow. It holds read-only content
only (number, domain, date, full title, snippet, URL) — a tooltip a pointer
cannot enter must never contain something clickable. Set `preview={false}` for
a dense strip; the list variant already shows a two-line excerpt inline.
- Identity changes reset the disclosure: a new `items` set (compared by a joined
id token, during render) restores `defaultExpanded`, so the next answer's list
can't inherit the previous one's expansion.
- The component owns NO CLOCK. Dates come from the data and are formatted, never
computed against `Date.now()` — "3 days ago" would desync server and client and
make two screenshots of the same answer differ. Wrap `formatDate` if you want
relative time, with a clock you own.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for the cards,
bg-muted for the favicon tile and the citation pill, bg-primary +
text-primary-foreground for the highlighted number, ring-ring for the active
card and every focus-visible ring, border-destructive/40 + bg-destructive/5 +
text-destructive for the error envelope, border-dashed for the empty one. No
hard-coded colours anywhere; the dark theme is whatever the tokens say.
- The crossed-out favicon is a 45°-rotated 1px bar at 145% width (√2 corner to
corner) over a grayscale, half-opacity image — a SHAPE, so it survives a
monochrome theme, plus a struck-through title and an sr-only "link no longer
reachable" for a screen reader.
- Long titles clamp to two lines (`line-clamp-2`) with the full text in the
preview; hosts and URLs use `wrap-anywhere` + `min-w-0`, NOT `break-words`,
because only overflow-wrap: anywhere lowers the min-content width that would
otherwise widen the whole strip for one 130-character URL.
- The root is a <section aria-label="Sources"> holding an <ol> — the list IS
ordered, and the numbers are its point. The loading branch is aria-busy with an
sr-only role="status"; the error branch is a role="alert"; the toggle is a real
<button aria-expanded aria-controls> pointing at the always-mounted <ol>.
- Skeletons mirror the real two-level structure (track item → card) so the ghosts
occupy exactly the box the sources will land in and the answer above does not
jump when they resolve. They stop pulsing under reduced motion.
- cn() merges the consumer's className into the root, which also spreads the
remaining element props and forwards its ref.
Customization levers
- Shape: `variant` picks strip vs list; `maxVisible` sets how many cards precede
the overflow control (raise it for a wide answer column, drop it to 3 in a
sidebar); `defaultExpanded` opens a research-grade list flat from the start.
- Density: `preview={false}` removes the tooltip layer entirely; `heading={null}`
drops the header row; swap the row card's fixed width (w-44) and the expanded
grid's minmax(11rem) together — they are the same "natural card width" knob.
- Wiring: `activeN` is the whole cross-highlight protocol — feed it the number of
the inline marker under the pointer, or the number of the sentence the reader
just scrolled to. `onSelect` swaps navigation for your own reader panel.
- Language and format: `formatDate` owns the date wording (return null to print
none), `heading` / `label` / `errorMessage` / `emptyState` own every string the
component would otherwise choose for you.
- Data policy: point `faviconUrl` at your own favicon proxy to avoid leaking your
users' reading list, or omit it entirely and ship monograms.Concepts
- The number is authored, not counted —
ncomes from whatever wrote the answer and matches the inline[n]in the prose. Deriving it from the array index would renumber the answer every time the list is sorted, filtered or deduplicated, and a citation that points at the wrong source is worse than no citation. - Expand before you scroll — highlighting a source that the collapsed view hides has to grow the list first, in the same render, because
scrollIntoViewon an unmounted node is a silent no-op. Doing it in an effect paints one frame with the target still missing and then scrolls to nothing. - Overflow is a tile, never a cut — the hidden sources sit behind an explicit "+4 more" you can count and click. A strip that just clips at the container edge tells the reader nothing was omitted, which is exactly the case where they would have wanted to look.
- A dead source refuses to link — the answer was grounded in it, so it stays on screen with a crossed-out favicon and a struck-through title; what it will not do is pretend to be a working link and hand the reader a 404. It stays focusable so its excerpt and its explanation are still reachable by keyboard.
- Reveal on hover AND on focus — the excerpt is attached to a tooltip that opens for a pointer and for a Tab, is portalled past the chat bubble's
overflow-hidden, and holds read-only content only: a surface a pointer cannot travel into must never contain something you are meant to click. - Modified clicks belong to the browser — intercepting a plain left click to open your own reader is fine; swallowing ⌘-click, middle-click and Shift-click breaks the one gesture every reader of a source list already has in their fingers.
- No clock inside the component — dates are formatted from the data in a fixed locale and in UTC, never computed against
Date.now(), so the server, the browser and a screenshot of the same answer all print the same string.
Conversation Export
An export menu for a whole conversation — scope radio, a size estimate on every format, and a row that spins, ticks or turns into Retry without the menu ever closing under it.
Confidence Meter
A response-confidence reading — segmented bar or small ring banded by your own cut points, with a 'why' disclosure that breaks the score into per-signal factor rows.