Xzero UI
Pro library

AI answerPro

One AI answer set like a clean article: it thinks, shows its sources as small cards with colored marks, and streams a formatted answer where every citation lights the sentence it supports. Copy, regenerate with options, rate, share, page through versions and ask a related question in place.

$ pnpm dlx shadcn@4.23.0 add @xzero-ui-pro/ai-answer
Live · keyboard ready

Pass question and respond, an async generator that yields reasoning deltas, the sources, text deltas in markdown with [n] citations, and a done event. Throw to show the error state; the AbortSignal fires on Stop, a new question and unmount. Pass answer, sources and usage to render a stored answer at once, and wire onFeedback, onFollowUp and onShare. With no props it runs a scripted demo.

When to use

When not to use

Installation

Register both Xzero UI namespaces in your project's components.json:

{
  "registries": {
    "@xzero-ui": "https://mcp.xzero.co.uk/r/{name}.json",
    "@xzero-ui-pro": "https://mcp.xzero.co.uk/r/pro/{name}.json"
  }
}
npx shadcn@4.23.0 add @xzero-ui-pro/ai-answer

Install xzero-foundation once, and import @/components/xzero/foundation.css in your app root. No Xzero token is used by this snapshot. For sample portraits, photos, fonts, or logos, download https://mcp.xzero.co.uk/install-assets.mjs and run node xzero-install-assets.mjs <receiving-project>.

Usage

import { AiAnswer, type AnswerEvent } from "@/components/xzero/blocks/ai-answer/ai-answer";

// Wire a real model: forward your server's stream as AnswerEvents.
async function* respond({ question, mode, model, signal }) {
  const response = await fetch("/api/answer", { method: "POST", body: JSON.stringify({ question, mode, model: model.id }), signal });
  if (!response.ok) throw new Error("The model is busy. Try again in a moment.");
  for await (const event of readNdjson<AnswerEvent>(response.body!)) yield event;
}

export function AnswerPage() {
  return (
    <AiAnswer
      question="How do we roll out passkeys without locking anyone out?"
      respond={respond}
      onFeedback={feedback => track("answer_feedback", feedback)}
      onFollowUp={question => router.push(`/ask?q=${encodeURIComponent(question)}`)}
      onShare={({ version }) => `https://acme.dev/answers/42${version ? `?v=${version + 1}` : ""}`}
    />
  );
}

// Or show a stored answer without streaming.
<AiAnswer
  question="What should we measure before each rollout stage?"
  answer={[
    { type: "paragraph", text: "Measure whether people get in, not just whether they enroll. [1]" },
    { type: "list", ordered: true, items: ["Enrollment", "Passkey success", "Fallbacks"] },
  ]}
  sources={[{ id: "webdev", title: "Passkey form autofill", site: "web.dev", domain: "web.dev", url: "https://web.dev/articles/passkey-form-autofill" }]}
  usage={{ tokens: 1240, thoughtSeconds: 7, seconds: 18.4 }}
/>

API reference

AiAnswer

One AI answer in a calm app view. A slim top bar carries the thread title, New thread and Share. The question sits above a reasoning row that shimmers "Thinking" and settles to "Thought for 9s", which opens a short reasoning summary, and a sources row with a stack of small site marks that opens into source cards. The answer streams in chunks with headings, numbered lists, a comparison table, a code block with copy and inline citations; hovering or focusing a citation shows its source and softly lights the exact sentence it supports and the matching card. Under the answer: copy, regenerate (Try again, More concise, More detailed, Switch model), thumbs up, thumbs down with a reason, a share link, a version switcher after regenerating, the model line with tokens and time, and three related questions. A docked composer asks follow-ups and turns into Stop while an answer streams.

PropTypeDefaultDescription
questionstring"How do we roll out passkeys without locking anyone out?"The question. Leave it out for the demo. An empty string, with no answer, shows the empty state with suggestions.
answerAnswerBlock[]–A finished answer to show at once, without streaming. Blocks are { type: "paragraph" | "heading", text }, { type: "list", ordered?, items }, { type: "table", columns, rows } and { type: "code", code, language?, filename? }. Inline text supports bold, code and [n] citations.
sourcesAnswerSource[]–Sources for a finished answer, numbered in order: [1] in the text is the first. Each is { id, title, site, domain, url, date?, snippet?, icon? }; icon is the site's mark (about 16px, brand colors kept), and the domain's first letter stands in without one.
reasoningstring–Reasoning summary for a finished answer, shown under "Thought for". Paragraphs split on blank lines.
usage{ tokens?: number; thoughtSeconds?: number; seconds?: number }–Usage for a finished answer, shown on the model line and the reasoning row.
followUpsstring[]–Related questions under the answer. The demo offers three from its script.
suggestionsstring[]–Questions offered in the empty state. The demo offers its four sample questions; with respond the default is none.
titlestring–Title in the top bar. Defaults to the question.
modelAnswerModelClaude Opus 5.5The model that writes the answer: { id, name, mark? }. The mark shows at 14px on the model line.
modelsAnswerModel[]Claude Opus 5.5, Sonnet 5.5, Haiku 5.5, Fable 5.1Models offered under Regenerate, Switch model. The first is the default when model is not set.
respond(request: AnswerRequest) => AsyncIterable<AnswerEvent>–Streams an answer from your model. The request has { question, mode, model, previous?, signal }; mode is "answer", "retry", "concise", "detailed" or "model". Yield { type: "reasoning", text }, { type: "sources", sources }, { type: "thought", seconds }, { type: "text", text } (markdown) and { type: "done", tokens?, seconds?, followUps? }. Throw to show the error state; stop when signal aborts. Without it, a scripted demo answers four sample questions.
onFeedback(feedback: AnswerFeedback) => void–Called with { value: "up" | "down" | null, reason?, question, answer, model, version } on thumbs up, on thumbs down after a reason is picked, and with null when feedback is taken back.
onFollowUp(question: string) => void–Called when a question is asked from the composer, a related question or a suggestion, just before its answer streams.
onShare(info: { question: string; answer: string; version?: number }) => string | Promise<string>–Returns the link that Share copies. version is set for the action bar link and undefined for the thread link in the top bar. Without it, a sample example.com link is copied and the status says so.
classNamestring–Class on the root element. The root fills the viewport height by default; set a height here to embed it.

Keyboard interactions

KeysAction
TabMoves through New thread, Share, the reasoning and sources rows, the source cards, each citation, the table and code regions, code Copy, the action bar, the related questions and the composer.
Enter, SpaceOn the reasoning or sources row, opens or closes it. On a citation, opens the source in a new tab; focusing it already shows the source card and lights its sentence and card.
EnterIn the composer, sends the question. Shift+Enter adds a line.
Arrow up / Arrow downIn the Regenerate menu, moves between options; Arrow right opens Switch model and Arrow left closes it.
EscapeCloses the Regenerate menu, the reason picker or a citation card and returns focus to its trigger.

Accessibility

Motion

Responsive behavior

Performance

Notes for AI

Guidance for AI tools

Blocks are complete, self-contained screens with sample data. Replace the sample data and connect the callbacks described above. Follow the declared prop types and do not invent props. Keep keyboard access, reduced motion support, and both light and dark themes intact when adapting it.

Full library index: https://ui.xzero.co.uk/llms.txt

Browse all components