An AI answer you can check: numbers in the text are buttons that light up every claim a source supports and open its excerpt, and the source list works the other way round. Says how many sentences are cited.
What changed
- 1.0.1 Oct 25, 2026The number buttons are larger and spaced apart, so they are easy to hit on a touch screen.
- 1.0.0 Oct 25, 2026First release.
Can I get my money back for the damaged speaker?
Yes. Because the speaker arrived damaged, you are covered by the 14-day refund policy, and return shipping is free.
Send a photo with your request and a prepaid label arrives within one business day. You can take the refund to your card, or keep it as store credit that never expires. I would start with the photo, since it is the quickest step.
4 of 5 sentences are cited
"use client"
import { CitedAnswer } from "@/components/ballmac/cited-answer"
const sources = [
{ title: "Refund policy", site: "help.acme.com", url: "https://help.acme.com/refunds", date: "Updated Oct 2", snippet: "If an item does not work as described and we cannot fix it within 14 days of your request, you receive a full refund to the original payment method." },
{ title: "Shipping and returns FAQ", site: "acme.com", url: "https://acme.com/faq/returns", date: "Sep 18", snippet: "Return shipping is free for damaged items. Send a photo with your request and we will email a prepaid label within one business day." },
{ title: "Store credit terms", site: "help.acme.com", url: "https://help.acme.com/credit", date: "Aug 30", snippet: "Store credit never expires and can be used on any order. It can be converted back to the original payment method on request." },
]
export default function CitedAnswerDemo() {
return (
<div className="w-full max-w-3xl">
<CitedAnswer
question="Can I get my money back for the damaged speaker?"
sources={sources}
paragraphs={[
"Yes. Because the speaker arrived damaged, you are covered by the 14-day refund policy [1], and return shipping is free [2].",
"Send a photo with your request and a prepaid label arrives within one business day [2]. You can take the refund to your card, or keep it as store credit that never expires [1, 3]. I would start with the photo, since it is the quickest step.",
]}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/cited-answerInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18n @ballmac/citationCopy the source into your project.
components/ballmac/cited-answer.tsx// Ballmac UI: Cited Answer. https://ui.ballmac.com/components/cited-answer "use client" import * as React from "react" import { ExternalLink, Quote } from "lucide-react" import { SourceFavicon, type CitationSource } from "@/components/ballmac/citation" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type CitedAnswerProps = Omit<React.ComponentProps<"div">, "children"> & { /** The answer, one string per paragraph. Write [1] or [2, 3] after a claim to cite the sources in that order. */ paragraphs: string[] /** The sources the numbers point to, in order. */ sources: CitationSource[] /** Shown above the answer, for example the question that was asked. */ question?: string /** Show how many sentences are cited. Defaults to true. */ showCoverage?: boolean } type Piece = { text: string; cites: number[] } const MARK = /\s*\[(\d+(?:\s*,\s*\d+)*)\]/g /** Splits a paragraph into pieces: each is the text before a [n] marker and the sources it cites. Text after the last marker has no cites. */ function parseParagraph(text: string, count: number): Piece[] { const out: Piece[] = [] let last = 0 for (const m of text.matchAll(MARK)) { const cites = m[1].split(",").map((n) => Number(n.trim()) - 1).filter((n) => n >= 0 && n < count) const body = text.slice(last, m.index) last = (m.index ?? 0) + m[0].length if (out.length > 0 && body === "" && cites.length > 0) out[out.length - 1].cites.push(...cites) else out.push({ text: body, cites }) } const rest = text.slice(last) if (rest.trim() !== "") out.push({ text: rest, cites: [] }) return out } /** * An AI answer you can check. Numbers in the text are real buttons; choosing one (or hovering it) lights up every claim that source supports and opens its * excerpt in the list beside it, and choosing a source in the list does the same the other way round. The count of cited sentences is said in words. */ function CitedAnswer({ paragraphs, sources, question, showCoverage = true, className, ...props }: CitedAnswerProps) { const msg = useMessages() const id = React.useId() const [pinned, setPinned] = React.useState<number | null>(null) const [hover, setHover] = React.useState<number | null>(null) const [announce, setAnnounce] = React.useState("") const shown = hover ?? pinned const parsed = React.useMemo(() => paragraphs.map((p) => parseParagraph(p, sources.length)), [paragraphs, sources.length]) const stats = React.useMemo(() => { const uses = sources.map(() => 0) let cited = 0 let total = 0 for (const para of parsed) { for (const piece of para) { const sentences = piece.cites.length > 0 ? 1 : (piece.text.match(/[^.!?]+[.!?]*/g) ?? []).filter((s) => s.trim().length > 3).length total += sentences if (piece.cites.length > 0) { cited += 1 for (const c of new Set(piece.cites)) uses[c] += 1 } } } return { uses, cited, total } }, [parsed, sources]) function pick(i: number) { const next = pinned === i ? null : i setPinned(next) setAnnounce( next === null ? msg("cited-answer.cleared", "Showing the whole answer") : msg("cited-answer.picked", "Highlighting {count} claims from {title}", { count: stats.uses[i], title: sources[i].title }) ) } return ( <div data-slot="cited-answer" className={cn("grid min-w-0 gap-5 rounded-xl border bg-card p-5 text-card-foreground md:grid-cols-[minmax(0,1fr)_17rem] md:gap-6 md:p-6", className)} onKeyDown={(e) => { if (e.key === "Escape" && pinned !== null) { setPinned(null) setHover(null) setAnnounce(msg("cited-answer.cleared", "Showing the whole answer")) } }} {...props} > <div className="min-w-0"> {question && ( <p className="mb-3 flex items-start gap-2 text-sm font-medium text-foreground text-balance"> <Quote aria-hidden="true" className="mt-0.5 size-3.5 shrink-0 text-muted-foreground rtl:-scale-x-100" /> {question} </p> )} <div className="grid gap-3 text-[15px] leading-[1.7] text-foreground/90 text-pretty"> {parsed.map((para, pi) => ( <p key={pi}> {para.map((piece, i) => { const on = shown !== null && piece.cites.includes(shown) const unique = [...new Set(piece.cites)] return ( <React.Fragment key={i}> <span data-claim={piece.cites.length > 0 ? "" : undefined} className={cn( "rounded-[3px] transition-colors duration-200 motion-reduce:transition-none", piece.cites.length > 0 && "box-decoration-clone decoration-foreground/25 underline decoration-dotted underline-offset-4", on && "bg-accent text-foreground decoration-transparent", shown !== null && piece.cites.length > 0 && !on && "text-muted-foreground" )} > {piece.text} </span> {unique.length > 0 && ( <span className="ms-1 inline-flex gap-1 align-baseline"> {unique.map((c) => ( <button key={c} type="button" aria-pressed={pinned === c} aria-label={msg("cited-answer.chip", "Source {n}: {title}", { n: c + 1, title: sources[c].title })} onClick={() => pick(c)} onMouseEnter={() => setHover(c)} onMouseLeave={() => setHover(null)} onFocus={() => setHover(c)} onBlur={() => setHover(null)} className={cn( "inline-flex h-[1.3rem] min-w-[1.3rem] -translate-y-px items-center justify-center rounded-full border px-1 text-[11px] font-medium leading-none tabular-nums outline-none transition-colors motion-reduce:transition-none", "focus-visible:ring-[3px] focus-visible:ring-ring/50", shown === c ? "border-foreground bg-foreground text-background" : "bg-background text-muted-foreground hover:border-foreground/50 hover:text-foreground" )} > {c + 1} </button> ))} </span> )} </React.Fragment> ) })} </p> ))} </div> {showCoverage && stats.total > 0 && ( <p className="mt-4 flex items-center gap-2 border-t pt-3 text-xs text-muted-foreground"> <span aria-hidden="true" className="h-1 w-16 overflow-hidden rounded-full bg-muted"> <span className="block h-full rounded-full bg-foreground" style={{ width: `${Math.round((stats.cited / stats.total) * 100)}%` }} /> </span> {msg("cited-answer.coverage", "{cited} of {total} sentences are cited", { cited: stats.cited, total: stats.total })} </p> )} </div> <div className="min-w-0 border-t pt-4 md:border-s md:border-t-0 md:pt-0 md:ps-6"> <h3 id={`${id}-sources`} className="mb-2 flex items-center justify-between text-xs font-medium text-muted-foreground"> {msg("cited-answer.sources", "Sources")} <span className="rounded-full bg-muted px-1.5 text-[11px] tabular-nums">{sources.length}</span> </h3> <ol aria-labelledby={`${id}-sources`} className="grid grid-cols-[minmax(0,1fr)] gap-1.5"> {sources.map((s, i) => { const on = shown === i return ( <li key={i} className={cn("grid min-w-0 gap-2 rounded-lg border p-2 transition-colors motion-reduce:transition-none", on ? "border-foreground/40 bg-accent/50" : "border-transparent")}> <div className="flex min-w-0 items-center gap-2"> <button type="button" aria-pressed={pinned === i} aria-label={msg("cited-answer.row", "{title}, cited {count} times. Highlight its claims", { title: s.title, count: stats.uses[i] })} onClick={() => pick(i)} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)} className="flex min-w-0 flex-1 items-center gap-2 rounded-md text-start outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <span aria-hidden="true" className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-medium tabular-nums">{i + 1}</span> <SourceFavicon source={s} /> <span className="min-w-0 flex-1"> <span className="block truncate text-[13px] font-medium leading-tight">{s.title}</span> <span className="block truncate text-[11px] text-muted-foreground">{[s.site, s.date].filter(Boolean).join(" · ")}</span> </span> <span aria-hidden="true" className="shrink-0 text-[11px] text-muted-foreground tabular-nums">{msg("cited-answer.times", "×{count}", { count: stats.uses[i] })}</span> </button> <a href={s.url} target="_blank" rel="noreferrer" aria-label={msg("cited-answer.open", "Open {title}", { title: s.title })} className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > <ExternalLink aria-hidden="true" className="size-3.5 rtl:-scale-x-100" /> </a> </div> {on && s.snippet && <p className="border-s-2 border-foreground/30 ps-2.5 text-xs leading-relaxed text-muted-foreground text-pretty">{s.snippet}</p>} </li> ) })} </ol> </div> <p aria-live="polite" className="sr-only">{announce}</p> </div> ) } export { CitedAnswer, parseParagraph, type CitedAnswerProps }Update the import paths to match your project setup.
Usage
import { CitedAnswer, parseParagraph } from "@/components/ballmac/cited-answer"The full example is in the Code tab above.
Examples
One source
"use client"
import { CitedAnswer } from "@/components/ballmac/cited-answer"
export default function CitedAnswerSingle() {
return (
<div className="w-full max-w-2xl">
<CitedAnswer
showCoverage={false}
sources={[{ title: "Plans and pricing", site: "acme.com", url: "https://acme.com/pricing", snippet: "The Team plan includes up to ten people for $349, once." }]}
paragraphs={["The Team plan covers up to ten people for a single payment of $349 [1]."]}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
paragraphs*The answer, one string per paragraph. Write [1] or [2, 3] after a claim to cite the sources in that order. | string[] | — |
sources*The sources the numbers point to, in order. | CitationSource[] | — |
questionShown above the answer, for example the question that was asked. | string | — |
showCoverageShow how many sentences are cited. Defaults to true. | boolean | true |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Numbers | Buttons named 'Source n: title' with pressed state; they work with Enter and Space and show focus |
| Sources | Each row is a button naming the source and how often it is cited; a separate link opens it |
| Highlight | Announced politely ('Highlighting 2 claims from …'); Escape clears; cited claims are underlined as well as tinted |
| Coverage | The count of cited sentences is text, not only a bar |
Use with AI
paragraphs is string[]; write [1] or [2, 3] after a claim to cite sources by their position in sources ({ title, url, site?, date?, snippet?, favicon? } as in citation). question shows above. showCoverage (default true) adds 'N of M sentences are cited'. Choosing or hovering a number, or a source row, highlights every claim that source supports and opens its snippet; Escape clears. Unknown numbers are ignored. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Cited Answer (@ballmac/cited-answer) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A RAG or search answer where people need to see where each claim came from
- Support and research assistants that must be checkable
Not for
- A plain chat reply with no sources; use ai-message
- Checking claims against sources automatically; use citation-coverage
Registry JSON: https://ui.ballmac.com/r/cited-answer.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Message Actions
The small bar under an AI reply: copy, try again, good or bad answer (with reasons), branch, share. A real toolbar with arrow-key movement, spoken results, and choices taken back with the reason when they cannot be saved.
Sources List
The 'Used N sources' footer of an AI answer: a stack of site icons that opens a numbered list or card grid, with show-all, a highlighted row for hover sync and stable row ids.
Citation
An inline source marker for AI answers, as a numbered chip or a site pill. Hover or focus opens a preview card with title, excerpt and link, and a pager when one claim has several sources.
Citation Coverage
An AI answer shown claim by claim: a coverage meter, claims with no source underlined and marked in words, numbered source markers, selecting a claim to light up its sources and quote, and a switch to see only the gaps.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.