Citation chip A source attached to the claim rather than to the whole answer. The chip earns trust whether or not the page behind it supports the sentence, which is why the expanded card has to show the passage and the date. npx shadcn@latest add hover-card badge npm i lucide-react Tokens this needs: --card, --card-foreground, --popover, --popover-foreground, --muted-foreground, --border, --status-warn, --chart-1 The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // CitationChip.tsx ──────────────────────────────────────────────────────────────────────── import { Lock } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; /** * A source is four things, and the date is one of them. A chip that cannot * say when its page was written is pointing at something the reader cannot * weigh, so `date` is required rather than an optional line in the card. */ export type Source = { title: string; /** What the chip shows. Short, because it sits inside a sentence. */ site: string; /** The full host, shown in the card. Domain is the fastest trust signal. */ domain: string; /** As the source states it: "March 2026", "12 May 2024". */ date: string; url: string; /** The passage the claim was drawn from. Missing means the attribution was reconstructed, and the card says so. */ passage?: string; /** Paywalled or permission-restricted. Marked before the reader clicks, since a chip they cannot open is worse than none. */ gated?: boolean; }; /** One sentence and what stands behind it. `null` is a real value: it means the * sentence is the model's own, and the paragraph shows that rather than hiding it. */ export type Claim = { text: string; source: Source | null }; export function CitationChip({ source, defaultOpen = false, onOpen }: { source: Source; /** Start with the card showing, as in a demo. */ defaultOpen?: boolean; /** Fires when the card opens. Count these against clicks through: it is the number this pattern is measured by. */ onOpen?: (source: Source) => void; }) { return ( o && onOpen?.(source)}> {source.gated && } {source.site}

{source.title}

{source.domain} · {source.date}

{source.passage ?? "No passage recorded. The link was matched to this sentence after the answer was written."}

); } /** * The paragraph, with the chip on the clause it belongs to. A sentence with * nothing behind it is marked, because chips only mean something when the * reader can see which parts have none. */ export function CitedParagraph({ claims, openSource, onOpen }: { claims: Claim[]; /** Which source's card starts open, by url. */ openSource?: string; onOpen?: (source: Source) => void; }) { // A div, not a p: the chip is a hover card whose content holds paragraphs, // and a p cannot contain them. return (
{claims.map((c, i) => ( {c.text} {c.source ? : no source} {i < claims.length - 1 && " "} ))}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { CitedParagraph, type Source } from "./CitationChip"; /** * Three sentences about the slow build's log stream. Two are drawn from pages * the assistant retrieved and carry a chip; the third is the model's own * inference and is marked as such. The MDN card starts open. */ const W3C: Source = { title: "Server-Sent Events, W3C Recommendation", site: "w3.org", domain: "www.w3.org", date: "February 2015", url: "https://www.w3.org/TR/eventsource/", passage: "The EventSource interface is used to receive server-sent events. It connects to a server over HTTP and receives events in text/event-stream format without closing the connection.", }; const MDN: Source = { title: "Using server-sent events", site: "mdn.org", domain: "developer.mozilla.org", date: "March 2026", url: "https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events", passage: "The event stream is a simple stream of text data which must be encoded using UTF-8. Messages in the event stream are separated by a pair of newline characters.", }; export default function Demo() { return (
console.log("opened", s.domain)} claims={[ { text: "Server-sent events keep one HTTP response open and push text down it as the build writes.", source: W3C }, { text: "Each message is a block of data: lines ended by a blank line, and the browser's EventSource object hands it to a listener and reconnects on its own if the connection drops.", source: MDN }, { text: "For a build log this is cheaper than polling and simpler than a WebSocket.", source: null }, ]} />
); }