AI Answer
A search-answer section — the question, the sources it stands on, prose whose every sentence carries a numbered chip back to a source, follow-ups, and a streaming pass where sources land before the text.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/ai-answer.jsonPrompt
Build a React + TypeScript + Tailwind "AiAnswer" block with zod and
lucide-react, reusing a shadcn/Radix Tooltip for the citation reveal.
Contract
- One zod schema in a sibling contract file is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; query: string;
segments: { id, kind?: "text" | "bullet" | "heading", text,
citationRefs: number[], startsParagraph? }[];
sources: { id, n, title, url, domain, snippet?, faviconUrl? }[];
followUps: { id, text }[];
streaming?: boolean }.
- A SEGMENT is a sentence, not a paragraph — a sentence is the unit a citation
attaches to, and a streaming generator emits them one at a time.
- `source.n` is the citation NUMBER assigned upstream by whatever produced the
answer; `segment.citationRefs` holds those same numbers. Neither is an array
index: filtering or re-ordering the sources for display must never renumber
the prose.
- `streaming` is ORTHOGONAL to status. `loading` means nothing arrived yet;
`ready` + `streaming` means retrieval finished and the text is still being
written. Model it as data, never as an internal timer — a state you can only
reach by waiting is a state nobody can screenshot or review.
- `faviconUrl` is supplied by the caller. Never guess a third-party favicon
endpoint; that hands every source your readers open to someone else's server.
With no URL, draw a monogram tile from the domain.
- Props = the schema fields plus: onFollowUp?(followUp); onCitationSelect?
(source); onSourceSelect?(source, event); onRetry?; target? ("_blank");
headingLevel? ("h2"); preview? (true); delayDuration? (250);
loadingLines? (4, clamped >= 1); emptyState?; errorMessage?; className and
the rest of the section's native props, with the ref forwarded to <section>.
Behavior
- FOLD SENTENCES INTO PROSE. Rendering one block per segment produces a stack
of one-line paragraphs that reads like a receipt. Group consecutive `text`
segments into one <p> (joined by a space), consecutive `bullet` segments into
one <ul>, and let `heading` open a new section. `startsParagraph: true`
forces a break inside a run of text. The grouping is a pure function of the
array — no state, safe to recompute on every render, and stable while
streaming appends to the end.
- CHIPS. After each segment's text, render its `citationRefs` — de-duplicated,
authored order preserved ("…[2][2]" is a rendering bug, not data). A resolved
ref is a <button> carrying `source.n`, sized so it cannot change the
paragraph's line height (fixed small box, leading-none, aligned middle,
nudged up a pixel). An UNRESOLVED ref — the generator cited something the
retriever never returned — renders as an inert dashed <span> with an sr-only
"(source unavailable)": the number stays visible because the model claimed
it, but an affordance that reveals nothing is worse than a footnote.
- CROSS-HIGHLIGHT, BOTH DIRECTIONS. Hovering or focusing a chip rings the
matching source card; hovering or focusing a source card tints every sentence
whose `citationRefs` contain that number. One piece of state — the "active
number" — drives both, so the two directions can never disagree.
- PIN AND SCROLL. Clicking a chip pins that number (sticky highlight that
survives the pointer leaving), scrolls the matching card into view with
`block: "nearest", inline: "nearest"` so the page does not jump, and calls
`onCitationSelect`. Clicking the same chip again unpins — a highlight you
cannot switch off eventually lies about where you are. Effective highlight =
hovered ?? pinned.
- NEW ANSWER, CLEAN SLATE. Derive an identity token from the query plus the
source ids; when it changes, reset hover and pin DURING RENDER (not in an
effect), or the first frame of the new answer highlights the old one's source.
- STATES. `loading`: the query is already known — echo the heading, then ghost
a four-card sources grid and `loadingLines` prose lines. `error`: an alert
with `role="alert"`, the message, and "Try again" only when `onRetry` exists.
`empty` — or `ready` with zero segments and not streaming — says the
retrieved material did not answer the question, and still renders the sources
and the follow-ups, because a narrower question is the way out of a dead end.
`ready`: header, sources, prose, follow-ups.
- STREAMING. Sources render in full, prose renders as far as it got, a blinking
caret trails the last segment, and two ghost lines hold the space below so the
card stops jumping every time a sentence lands. Set `aria-busy` on the section
and announce "the answer is still being written" once in an sr-only
`role="status"` — never re-announce the growing text. Follow-ups stay hidden
until the stream ends: a suggestion is a claim that the answer is finished.
- FOLLOW-UPS ARE REAL OR ABSENT. Render the block only when `onFollowUp` is
wired. A chip that looks clickable and does nothing is the most expensive
thing in this component.
- SOURCE CARDS ARE REAL LINKS (<a href> + target/rel), so middle-click and
⌘-click keep working. `onSourceSelect` intercepts only a plain left click
(bail out on defaultPrevented, meta/ctrl/shift/alt, or a non-zero button) for
hosts that open sources in a reader panel.
- FAVICONS MAY FAIL. Store the src that broke, not a boolean, so a card
recycled onto a different source retries the new image instead of inheriting
the old failure — and no effect is needed to reset it.
Rendering & styling
- Semantic tokens only: bg-card section, bg-background source cards, bg-muted
ghosts and inactive pills, text-muted-foreground for domains and meta,
bg-primary / text-primary-foreground for the active chip and the caret,
bg-primary/10 for highlighted sentences, border-destructive/40 and
bg-destructive/5 for the error panel. No hex, no rgb, no oklch.
- Sources sit in a wrapping grid — repeat(auto-fit, minmax(min(11rem,100%),
1fr)) — not a horizontal scroller, so every retrieved source stays visible at
every width instead of hiding past the right edge.
- Prose is `text-sm leading-7` so a 1rem chip never opens the line box; titles
clamp to two lines; long domains truncate; the heading and long queries use
text-pretty and min-w-0 to survive a 200-character question.
- cn() merges className everywhere. Every animation is decorative and carries
`motion-reduce:animate-none` / `motion-reduce:transition-none`; the one
behaviour a class cannot reach — scrollIntoView's `behavior` — reads
`matchMedia("(prefers-reduced-motion: reduce)")` through a hook that removes
its listener on unmount.
- a11y: the section is labelled by the query heading via aria-labelledby;
`headingLevel` keeps the document outline correct and a `heading` segment
renders exactly one level below it (h2 -> h3), so the answer's own sections
never read as siblings of the question; chips carry
`aria-label="Source n: title, domain"` and `aria-pressed` for the pin; every
interactive element has a focus-visible ring, and the whole cross-highlight is
reachable by Tab because focus drives it exactly like hover does.
Customization levers
- Density: drop `gap-5` to `gap-3` and the card padding to `p-4` for a chat
bubble; raise `leading-7` to `leading-8` for a reading surface.
- Sub-blocks: the header, sources row, prose and follow-ups are four
independent siblings — delete any one (or reorder sources below the prose,
the "answer first" variant) without touching the citation logic.
- Sources shape: swap the wrapping grid for `flex gap-2 overflow-x-auto` plus a
fixed card width to get the strip that scrolls; or cap the list and add a
"+N more" tile.
- Chip style: superscript numbers, bracketed `[n]`, or a favicon-in-chip — only
the box metrics matter, keep the height at or below the line height.
- Emphasis: `bg-primary/10` on cited sentences is the loudest choice; a bottom
border or a left rule reads quieter in dense documents.
- Streaming intensity: replace the caret with a shimmer on the last segment,
and tune the two ghost lines to match your average answer length.
- Reveal: set `preview={false}` for a dense mobile layout where the chip only
pins and scrolls, or raise `delayDuration` if hovering the prose feels twitchy.Concepts
- Sentence-level segments, paragraph-level rendering — the generator emits one sentence at a time because that is what a citation attaches to; the component folds consecutive sentences back into flowing paragraphs, so streaming granularity never leaks into the typography.
- Numbering authored upstream — chips and cards both print
source.n, never the array index, so re-ordering or filtering the sources for display cannot silently renumber the prose the reader is quoting. - Bidirectional cross-highlight — a single "active number" drives both directions: point at a chip to find its source, point at a source to see every sentence it backs. Focus drives it identically, so the whole relationship is reachable by keyboard.
- Pin versus hover — hover is a transient preview, a click pins the highlight and scrolls the card into view; the second click unpins, because a sticky highlight that cannot be cleared starts describing where you were, not where you are.
- Unresolved citation stays inert — when the model cites a source retrieval never returned, the number is still shown but is not a button: hiding it would edit the model's claim, and making it clickable would promise a reveal that does not exist.
- Streaming is data, not a timer —
ready + streamingis a value in the contract, so the mid-stream frame (sources landed, prose half-written, caret trailing, ghost lines below) can be rendered, screenshotted and reviewed like any other state.
Chatbot Widget
An embeddable support chat widget — launcher bubble with an acknowledged unread badge, a panel that keeps its draft when minimised, quick replies, and a rating row on the way out.
Research Report
The finished output of a deep-research run — prose whose citation numbers are derived from one walk of the data, per-section source chips, an outline that follows the reader, and a methodology footer that shows the queries run and the sources rejected.