DHxWhy

Cited Answer AI Chat Input with Sources

AI chats 21

About

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>
  );
}

Dependencies

More by DHxWhy

View all
  • Sun Moon Dark Mode Theme Toggle

    Sun Moon Dark Mode Theme Toggle

    186
  • Country Flags Globe

    Country Flags Globe

    110
  • Live Notification Feed

    Live Notification Feed

    109
  • Airplane Window Gallery

    Airplane Window Gallery

    95
  • Domino Scroll Image Gallery

    Domino Scroll Image Gallery

    93
  • QR Code Scan Login Form with Password Fallback

    QR Code Scan Login Form with Password Fallback

    82
  • Constellation Image Gallery

    Constellation Image Gallery

    80
  • Ferris Wheel Image Gallery

    Ferris Wheel Image Gallery

    73

More in AI chats

View all
  • AI Actions

    AI Actions

    319
  • Agent Chat

    Agent Chat

    17
  • System Message

    System Message

    54
  • Message Thread

    Message Thread

    35
  • Color Picker

    Color Picker

    81
  • AI Task List

    AI Task List

    160
  • Modal Card

    Modal Card

    101
  • Quota Banner

    Quota Banner

    21