AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ChevronDown, ExternalLink, Globe, Link2Off, RefreshCcw, SearchX } from "lucide-react"

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { AnswerSourcesItem, AnswerSourcesStatus } from "./answer-sources.contract"

/**
 * Fixed locale AND fixed time zone. A source list is rendered on the server, in
 * the browser and into a screenshot fixture; `toLocaleDateString()` would print
 * three different strings and hydrate with a mismatch warning. A published date
 * is also not a clock — nothing here reads `Date.now()`, so "3 days ago" is

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/answer-sources.json

Prompt

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 countedn comes 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 scrollIntoView on 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.

On This Page