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.
Source coverage
3 of 5 claims have a source
source 1 source 1source 2 source 3
This claim has no source.
A wavy underline means no source backs the claim.
Sources
1Refund policyexample.com/refund
If an item does not work as described and we cannot fix it within 14 days, we refund the full price.
2Payments FAQexample.com/payments
Refunds go back to the original payment method.
3Team licence termsexample.com/team
import { CitationCoverage } from "@/components/ballmac/citation-coverage"
export default function CitationCoverageDemo() {
return (
<div className="w-full max-w-4xl">
<CitationCoverage
defaultSelected="c2"
claims={[
{ id: "c1", text: "The refund window is 14 days from purchase.", sources: [1] },
{ id: "c2", text: "A refund is paid back to the original payment method.", sources: [1, 2] },
{ id: "c3", text: "Most refunds arrive within two days.", sources: [] },
{ id: "c4", text: "Team licences can be switched to a different organisation once a year.", sources: [3] },
{ id: "c5", text: "Support replies within one business day.", sources: [] },
]}
sources={[
{ id: 1, title: "Refund policy", url: "https://example.com/refund", quote: "If an item does not work as described and we cannot fix it within 14 days, we refund the full price." },
{ id: 2, title: "Payments FAQ", url: "https://example.com/payments", quote: "Refunds go back to the original payment method." },
{ id: 3, title: "Team licence terms", url: "https://example.com/team", quote: "A seat may move to another person with our written consent, once in any 90 days." },
]}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/citation-coverageInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/citation-coverage.tsx// Ballmac UI: Citation Coverage. https://ui.ballmac.com/components/citation-coverage "use client" import * as React from "react" import { CircleAlert, ExternalLink, Link2 } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type CoverageSource = { /** The number shown on the claims that use it. */ id: number title: string /** Where it lives. Shown as a link when given. */ url?: string /** A passage from it, shown when a claim that uses it is selected. */ quote?: string } type CoverageClaim = { id: string /** One sentence or statement from the answer. */ text: string /** The ids of the sources behind it. Empty means nothing backs it. */ sources: number[] } type CitationCoverageProps = Omit<React.ComponentProps<"div">, "onSelect"> & { /** The answer, split into claims in reading order. */ claims: CoverageClaim[] /** Everything the answer could cite. */ sources: CoverageSource[] /** The selected claim id. Controlled. */ selected?: string | null /** The selected claim id for uncontrolled use. */ defaultSelected?: string | null /** Called when a claim is selected, or null when it is cleared. */ onSelectedChange?: (id: string | null) => void } /** * An answer shown claim by claim, so a reader can see which statements are backed by a source and which are not. Unsupported claims are underlined and * marked in words; selecting a claim lights up its sources and shows the passage. A switch narrows the answer to the unsupported claims. */ function CitationCoverage({ claims, sources, selected: selectedProp, defaultSelected = null, onSelectedChange, className, ...props }: CitationCoverageProps) { const msg = useMessages() const uid = React.useId() const [inner, setInner] = React.useState<string | null>(defaultSelected) const selected = selectedProp !== undefined ? selectedProp : inner const [onlyGaps, setOnlyGaps] = React.useState(false) const supported = claims.filter((c) => c.sources.length > 0).length const percent = claims.length ? Math.round((supported / claims.length) * 100) : 0 const chosen = claims.find((c) => c.id === selected) const lit = new Set(chosen?.sources ?? []) const shown = onlyGaps ? claims.filter((c) => c.sources.length === 0) : claims function select(id: string) { const next = id === selected ? null : id if (selectedProp === undefined) setInner(next) onSelectedChange?.(next) } return ( <div data-slot="citation-coverage" className={cn("grid w-full min-w-0 gap-5 rounded-xl border bg-card p-4 text-card-foreground sm:p-5 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]", className)} {...props}> <div className="grid content-start gap-4"> <div className="grid gap-2"> <div className="flex items-baseline justify-between gap-3"> <p className="text-sm font-medium text-foreground">{msg("citation-coverage.title", "Source coverage")}</p> <p className="text-xs text-muted-foreground tabular-nums">{msg("citation-coverage.summary", "{supported} of {total} claims have a source", { supported, total: claims.length })}</p> </div> <div role="meter" aria-label={msg("citation-coverage.title", "Source coverage")} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} aria-valuetext={`${percent}%`} className="h-2 overflow-hidden rounded-full bg-muted"> <div className="h-full rounded-full bg-chart-2 transition-[width] duration-500 motion-reduce:transition-none" style={{ width: `${percent}%` }} /> </div> <label className="mt-1 flex w-fit cursor-pointer items-center gap-2 text-[13px]"> <input type="checkbox" checked={onlyGaps} onChange={(e) => setOnlyGaps(e.target.checked)} className="size-4 accent-foreground" /> {msg("citation-coverage.onlyGaps", "Show only claims without a source")} </label> </div> <p className="text-[15px] leading-8" aria-live="polite"> {shown.length === 0 ? ( <span className="text-muted-foreground">{msg("citation-coverage.none", "Every claim has a source.")}</span> ) : ( shown.map((c) => { const gap = c.sources.length === 0 const active = c.id === selected return ( <React.Fragment key={c.id}> <button type="button" aria-pressed={active} aria-describedby={gap ? `${uid}-gap` : undefined} onClick={() => select(c.id)} className={cn( "rounded-sm px-0.5 text-start outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none", gap ? "underline decoration-destructive decoration-wavy decoration-1 underline-offset-4 hover:bg-destructive/10" : "hover:bg-accent", active && "bg-accent" )} > {c.text} {gap && " "} {gap && <span className="sr-only">({msg("citation-coverage.noSource", "no source")})</span>} </button> {c.sources.map((s) => ( <sup key={s} className={cn("mx-px inline-flex min-w-4 items-center justify-center rounded px-1 font-mono text-[10px] font-medium tabular-nums", lit.has(s) ? "bg-foreground text-background" : "bg-muted text-muted-foreground")}> <span className="sr-only">{msg("citation-coverage.source", "source")} </span> {s} </sup> ))}{" "} </React.Fragment> ) }) )} </p> <p id={`${uid}-gap`} className="hidden">{msg("citation-coverage.gapHelp", "This claim has no source.")}</p> {claims.some((c) => c.sources.length === 0) && ( <p className="flex items-center gap-1.5 text-xs text-muted-foreground"> <CircleAlert aria-hidden="true" className="size-3.5 text-destructive" /> {msg("citation-coverage.legend", "A wavy underline means no source backs the claim.")} </p> )} </div> <div className="grid content-start gap-2"> <p className="text-sm font-medium text-foreground">{msg("citation-coverage.sources", "Sources")}</p> <ol className="grid gap-2"> {sources.map((s) => { const on = lit.has(s.id) return ( <li key={s.id} data-lit={on || undefined} className={cn("rounded-lg border p-3 text-[13px] transition-colors motion-reduce:transition-none", on && "border-foreground bg-accent/50")}> <p className="flex items-start gap-2"> <span className="mt-px flex size-5 shrink-0 items-center justify-center rounded bg-muted font-mono text-[11px] tabular-nums">{s.id}</span> <span className="min-w-0"> <span className="block font-medium text-foreground">{s.title}</span> {s.url && ( <a href={s.url} className="mt-0.5 inline-flex max-w-full items-center gap-1 truncate text-xs text-muted-foreground underline-offset-4 outline-none hover:text-foreground hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50" dir="ltr"> <ExternalLink aria-hidden="true" className="size-3 shrink-0" /> <span className="truncate">{s.url.replace(/^https?:\/\//, "")}</span> </a> )} </span> </p> {on && s.quote && ( <blockquote className="mt-2 flex gap-2 border-s-2 ps-3 text-xs leading-relaxed text-muted-foreground"> <Link2 aria-hidden="true" className="mt-0.5 size-3 shrink-0" /> {s.quote} </blockquote> )} </li> ) })} </ol> </div> </div> ) } export { CitationCoverage, type CitationCoverageProps, type CoverageClaim, type CoverageSource }Update the import paths to match your project setup.
Usage
import { CitationCoverage } from "@/components/ballmac/citation-coverage"The full example is in the Code tab above.
Examples
Fully sourced
Source coverage
2 of 2 claims have a source
source 1 source 1source 2
This claim has no source.
Sources
1Billing guideexample.com/billing
2Tax and invoicesexample.com/tax
import { CitationCoverage } from "@/components/ballmac/citation-coverage"
export default function CitationCoverageFull() {
return (
<div className="w-full max-w-4xl">
<CitationCoverage
claims={[
{ id: "a", text: "Invoices are generated on the first of each month.", sources: [1] },
{ id: "b", text: "Each one is a PDF with the company name and tax ID you entered at checkout.", sources: [1, 2] },
]}
sources={[
{ id: 1, title: "Billing guide", url: "https://example.com/billing" },
{ id: 2, title: "Tax and invoices", url: "https://example.com/tax" },
]}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
claims*The answer, split into claims in reading order. | CoverageClaim[] | — |
sources*Everything the answer could cite. | CoverageSource[] | — |
selectedThe selected claim id. Controlled. | string | null | — |
defaultSelectedThe selected claim id for uncontrolled use. | string | null | null |
onSelectedChangeCalled when a claim is selected, or null when it is cleared. | (id: string | null) => void | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab, Enter / Space | Each claim is a toggle button; selecting it shows its sources and quote |
| Screen readers | An unsupported claim reads '(no source)'; source markers read 'source 2'; the summary and the filtered answer are polite live regions |
| Colour | Gaps are marked by a wavy underline and words, never colour alone |
Use with AI
claims is [{ id, text, sources: number[] }] in reading order (empty sources means unsupported); sources is [{ id, title, url?, quote? }]. selected / defaultSelected / onSelectedChange pick a claim. Computes the coverage percentage itself. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Citation Coverage (@ballmac/citation-coverage) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing people how much of an AI answer is grounded in sources
- A review screen where unsupported claims need checking
Not for
- Plain inline citations in a chat message; use citation and sources-list
- A list of documents only; use sources-list
Registry JSON: https://ui.ballmac.com/r/citation-coverage.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
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.
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.
AI Message
A chat message with user, assistant and system roles: right-aligned user bubbles, full-width assistant prose, an avatar slot, hover-revealed actions and a hydration-safe timestamp.
Streaming Text
Text that arrives progressively, with a blinking caret while streaming, preserved whitespace, aria-busy for screen readers and an optional typewriter reveal for demos.
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.