Cited answer AI chat input for React and Tailwind CSS (shadcn/ui) that shows the sources an assistant is reading and footnotes every sentence of the streamed answer with colorful citation chips. It is built to make AI answers checkable at a glance, so people can see where each claim comes from before they trust it. A persistent composer sits below the transcript; while the assistant works, a shimmering "Reading sources" header lists each source with a chart-tinted tile, domain, and reading progress bar. Sentences stream in with numbered chips that match their source colors; hovering or focusing a chip lifts its source card, and hovering a card highlights every chip that cites it. Once done the list folds into a compact "sources" row that reopens on demand. Supports controlled status, sources, answer, and active source, sentence-level live announcements, IME-safe Enter, Stop and Send swapping, reduced motion, and light and dark themes. Use it for RAG assistants, research copilots, support bots, and documentation search. The included demo scripts an illustrative answer; the component makes no requests. Requires React and lucide-react, with no animation library.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Cited Answer AI Chat Input with Sources to your project and fits it to your theme.
Code
How Cited Answer AI Chat Input with Sources is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client";
import * as React from "react";
import { BookOpen, RotateCcw } from "lucide-react";
import { CitedAnswerChatInput } from "@/components/ui/cited-answer-chat-input";
import type { CitedSentence, CitedSource, CitedStatus } from "@/components/ui/cited-answer-chat-input";
const PROMPT = "Is cold brew really less acidic than hot coffee?";
export const DEMO_SOURCES: CitedSource[] = [
{ id: "lab", title: "Acidity of cold versus hot brewed coffee", domain: "coffeescience.example", tone: 1 },
{ id: "guide", title: "Brewing temperature and extraction basics", domain: "brewguide.example", tone: 2 },
{ id: "health", title: "Coffee and stomach comfort: what the data says", domain: "wellness.example", tone: 3 },
];
export const DEMO_ANSWER: CitedSentence[] = [
{ text: "Mostly yes: cold brew usually measures a slightly higher pH, so it tastes less sharp.", cite: ["lab"] },
{ text: "Lower temperatures pull out fewer of the acids that give hot coffee its bite.", cite: ["guide"] },
{ text: "The difference is small, and many people notice comfort gains only with lighter roasts.", cite: ["lab", "health"] },
];
type DemoProps = { autoplay?: boolean; loop?: boolean; bare?: boolean; stage?: number };
type DemoState = { status: CitedStatus; prompt: string; value: string; sources: CitedSource[]; answer: CitedSentence[]; active: string | null };
const initial = (): DemoState => ({ status: "idle", prompt: "", value: "", sources: [], answer: [], active: null });
export default function CitedAnswerDemo({ autoplay = true, loop = true, bare = false, stage = 464 }: DemoProps) {
const [state, setState] = React.useState(initial);
const [motion, setMotion] = React.useState({ ready: false, reduced: true });
const [run, setRun] = React.useState({ id: 0, source: "auto", prompt: PROMPT });
const [paused, setPaused] = React.useState(false);
React.useEffect(() => {
const media = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setMotion({ ready: true, reduced: media.matches });
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
React.useEffect(() => {
if (!motion.ready) return;
if (run.source === "auto" && (!autoplay || motion.reduced || paused)) return;
const timers: number[] = [];
const later = (ms: number, callback: () => void) => timers.push(window.setTimeout(callback, ms));
const example = run.prompt === PROMPT;
const sources = example ? DEMO_SOURCES : [{ id: "notes", title: "Your connected knowledge base", domain: "docs.example", tone: 4 as const }];
const answer: CitedSentence[] = example ? DEMO_ANSWER : [
{ text: "This is a local demo, so there is no live search behind it.", cite: ["notes"] },
{ text: `Your question was: “${run.prompt}”`, cite: [] },
{ text: "Connect your own retrieval pipeline to stream a cited answer here.", cite: ["notes"] },
];
const offset = run.source === "auto" ? 1000 : 0;
if (run.source === "auto") {
setState(initial());
for (let index = 1; index <= PROMPT.length; index += 1) later(200 + (index / PROMPT.length) * 620, () => setState((previous) => ({ ...previous, value: PROMPT.slice(0, index) })));
}
later(offset, () => setState({ status: "reading", prompt: run.prompt, value: "", sources: [], answer: [], active: null }));
sources.forEach((source, index) => {
const start = offset + 300 + index * 500;
later(start, () => setState((previous) => ({ ...previous, sources: [...previous.sources, { ...source, progress: 0.1 }] })));
[0.45, 0.8, 1].forEach((progress, step) => later(start + 200 + step * 200, () => setState((previous) => ({ ...previous, sources: previous.sources.map((s) => (s.id === source.id ? { ...s, progress } : s)) }))));
});
const answerAt = offset + 300 + sources.length * 500 + 400;
later(answerAt, () => setState((previous) => ({ ...previous, status: "answering", sources: previous.sources.map((s) => ({ ...s, progress: 1 })) })));
answer.forEach((_sentence, index) => later(answerAt + 200 + index * 700, () => setState((previous) => ({ ...previous, answer: answer.slice(0, index + 1) }))));
const doneAt = answerAt + 200 + answer.length * 700 + 100;
later(doneAt, () => setState((previous) => ({ ...previous, status: "done" })));
if (example) {
later(doneAt + 300, () => setState((previous) => ({ ...previous, active: "lab" })));
later(doneAt + 1300, () => setState((previous) => ({ ...previous, active: null })));
}
if (loop && run.source === "auto") later(doneAt + 2600, () => setRun((previous) => ({ ...previous, id: previous.id + 1 })));
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [autoplay, loop, motion.ready, motion.reduced, paused, run]);
const takeOver = () => setPaused(true);
return (
<div className="flex min-h-screen w-full items-center justify-center bg-background p-5 font-sans text-foreground">
<div className="w-full max-w-[480px]">
{!bare && (
<header className="mb-4">
<div className="mb-2 flex items-center gap-2 text-[10px] font-medium uppercase tracking-[0.2em] text-muted-foreground"><BookOpen className="size-3" aria-hidden="true" /> Every claim, footnoted</div>
<h1 className="text-[25px] font-medium leading-tight tracking-tight">Answers you can check.</h1>
</header>
)}
<div className="flex items-end" style={{ height: stage }} data-demo-stage>
<CitedAnswerChatInput
status={state.status}
prompt={state.prompt}
value={state.value}
onValueChange={(value) => { if (run.source === "auto") takeOver(); setState((previous) => ({ ...previous, value })); }}
sources={state.sources}
answer={state.answer}
activeSource={state.active}
onActiveSourceChange={(active) => setState((previous) => ({ ...previous, active }))}
onSubmit={(prompt) => { setPaused(false); setRun((previous) => ({ id: previous.id + 1, source: "user", prompt })); }}
onStop={() => { takeOver(); setRun((previous) => ({ ...previous, source: "auto" })); setState((previous) => ({ ...initial(), value: previous.prompt })); }}
answerNote={state.prompt === PROMPT ? "Sample answer · Sources are illustrative." : undefined}
/>
</div>
{!bare && (
<footer className="mt-4 flex items-center justify-between gap-3 text-[10px] text-muted-foreground">
<span>Sample conversation · No live search</span>
<button type="button" data-replay onClick={() => { setPaused(false); setRun((previous) => ({ id: previous.id + 1, source: "user", prompt: PROMPT })); }} className="flex items-center gap-1.5 rounded px-1 py-1 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"><RotateCcw className="size-3" aria-hidden="true" /> Replay demo</button>
</footer>
)}
</div>
</div>
);
}
















