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.
import { Citation, type CitationSource } from "@/components/ballmac/citation"
const sources: CitationSource[] = [
{
title: "How sea ice extent is measured from satellites",
url: "https://example.org/climate/sea-ice-extent",
site: "Polar Data Center",
date: "Mar 4, 2026",
snippet: "Passive microwave sensors record the ocean surface every day, and extent is the area with at least 15% ice cover.",
},
{
title: "Arctic summer minimum reaches the second lowest on record",
url: "https://example.com/news/arctic-minimum",
site: "Example News",
date: "Sep 21, 2026",
snippet: "The yearly minimum fell well below the 1981–2010 average, continuing a long decline.",
},
]
export default function CitationDemo() {
return (
<p className="max-w-md text-[15px] leading-7 text-foreground">
Arctic sea ice is tracked by satellites that sense microwave emission from the surface
<Citation index={1} sources={sources[0]!} />. This year's minimum was the second lowest since records began
<Citation index={2} sources={sources[1]!} />, and the trend across four decades is downward.
</p>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/citationInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/hover-card @ballmac/i18nCopy the source into your project.
components/ballmac/citation.tsx// Ballmac UI: Citation. https://ui.ballmac.com/components/citation "use client" import * as React from "react" import { ChevronLeft, ChevronRight, ExternalLink } from "lucide-react" import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ballmac/hover-card" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type CitationSource = { /** Page title. */ title: string /** Link to the page. */ url: string /** Short excerpt shown in the preview card. */ snippet?: string /** Shown instead of the host name, for example a publisher. */ site?: string /** Icon image URL. When omitted a colored monogram is drawn, so nothing is fetched from a third party. */ favicon?: string /** Publication date as text, for example "Mar 4, 2026". */ date?: string } const TONES = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"] function hostOf(url: string) { try { return new URL(url).hostname.replace(/^www\./, "") } catch { return url } } function hashOf(text: string) { let h = 0 for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0 return h } type SourceFaviconProps = React.ComponentProps<"span"> & { source: Pick<CitationSource, "url" | "site" | "favicon"> } /** A small rounded icon for a source: its favicon when given, otherwise the first letter of its site on a stable color. */ function SourceFavicon({ source, className, ...props }: SourceFaviconProps) { const name = source.site ?? hostOf(source.url) return ( <span data-slot="source-favicon" aria-hidden="true" className={cn( "flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-[6px] border border-black/5 text-[11px] font-semibold text-background uppercase dark:border-white/10", !source.favicon && TONES[hashOf(name) % TONES.length], className )} {...props} > {source.favicon ? ( // eslint-disable-next-line @next/next/no-img-element <img src={source.favicon} alt="" className="size-full object-cover" /> ) : ( name.charAt(0) )} </span> ) } type CitationProps = Omit<React.ComponentProps<"a">, "href" | "children"> & { /** The source, or several sources backing one claim. With several, the card gets a pager. */ sources: CitationSource | CitationSource[] /** Number shown in the marker. Ignored by the "pill" style. */ index?: number /** "number" is a small superscript-style chip like [1]. "pill" shows the site name with a +N count. */ variant?: "number" | "pill" } function Citation({ sources, index = 1, variant = "number", className, ...props }: CitationProps) { const msg = useMessages() const list = Array.isArray(sources) ? sources : [sources] const [page, setPage] = React.useState(0) const active = list[Math.min(page, list.length - 1)] if (!active) return null const first = list[0]! const host = first.site ?? hostOf(first.url) const extra = list.length - 1 const many = list.length > 1 return ( <HoverCard openDelay={120} closeDelay={150}> <HoverCardTrigger data-slot="citation" href={first.url} target="_blank" rel="noreferrer noopener" aria-label={ variant === "pill" ? `Source: ${host}${extra ? ` and ${extra} more` : ""}` : `Source ${index}: ${first.title}${extra ? ` and ${extra} more` : ""}` } className={cn( "mx-0.5 inline-flex items-center justify-center whitespace-nowrap align-baseline font-medium text-foreground no-underline outline-none transition-colors duration-150 focus-visible:ring-[3px] focus-visible:ring-ring/50", variant === "number" ? "h-[1.25em] min-w-[1.35em] -translate-y-[0.15em] rounded-md bg-muted px-1 text-[0.72em] tabular-nums hover:bg-foreground hover:text-background" : "h-6 gap-1 rounded-full border bg-background px-2 text-xs shadow-xs hover:bg-accent", className )} {...props} > {variant === "number" ? ( index ) : ( <> <span className="max-w-32 truncate">{host}</span> {extra > 0 && <span className="text-muted-foreground tabular-nums">+{extra}</span>} </> )} </HoverCardTrigger> <HoverCardContent data-slot="citation-card" className="grid w-[min(21rem,calc(100vw-1.5rem))] gap-2.5 p-3.5"> {many && ( <div className="flex items-center justify-between text-xs text-muted-foreground"> <span className="tabular-nums" aria-live="polite"> {msg("citation.sourceOf", "Source {n} of {total}", { n: page + 1, total: list.length })} </span> <span className="flex items-center gap-0.5"> <button type="button" aria-label={msg("citation.previousSource", "Previous source")} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))} className="inline-flex size-6 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40 disabled:hover:bg-transparent" > <ChevronLeft aria-hidden="true" className="size-4 rtl:rotate-180" /> </button> <button type="button" aria-label={msg("citation.nextSource", "Next source")} disabled={page === list.length - 1} onClick={() => setPage((p) => Math.min(list.length - 1, p + 1))} className="inline-flex size-6 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-40 disabled:hover:bg-transparent" > <ChevronRight aria-hidden="true" className="size-4 rtl:rotate-180" /> </button> </span> </div> )} <div className="flex items-center gap-2 text-xs text-muted-foreground"> <SourceFavicon source={active} /> <span className="truncate">{active.site ?? hostOf(active.url)}</span> {active.date && <span className="shrink-0 before:me-2 before:content-['·']">{active.date}</span>} </div> <p className="line-clamp-2 text-sm leading-5 font-medium text-foreground">{active.title}</p> {active.snippet && ( <p className="line-clamp-3 text-[13px] leading-5 text-muted-foreground">{active.snippet}</p> )} <a href={active.url} target="_blank" rel="noreferrer noopener" className="inline-flex w-fit items-center gap-1 rounded-sm text-xs font-medium text-foreground underline-offset-4 outline-none hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50" > {msg("citation.openSource", "Open source")} <ExternalLink aria-hidden="true" className="size-3" /> </a> </HoverCardContent> </HoverCard> ) } export { Citation, SourceFavicon, hostOf, type CitationProps, type CitationSource, type SourceFaviconProps }Update the import paths to match your project setup.
Usage
import { Citation, SourceFavicon, hostOf } from "@/components/ballmac/citation"The full example is in the Code tab above.
Examples
Site pills with several sources
import { Citation, type CitationSource } from "@/components/ballmac/citation"
const pricing: CitationSource[] = [
{
title: "Pricing and plans",
url: "https://example.com/pricing",
site: "Acme",
snippet: "Team plans start at $12 per seat each month when billed yearly. Volume discounts apply above 50 seats.",
},
{
title: "What changed in the March pricing update",
url: "https://example.org/blog/pricing-update",
site: "Acme Blog",
date: "Mar 12, 2026",
snippet: "Seats are now billed monthly, and the free tier includes three projects.",
},
{
title: "Acme pricing compared with alternatives",
url: "https://example.net/compare/acme",
site: "Example Reviews",
snippet: "A side-by-side look at seat prices, included usage and support.",
},
]
export default function CitationPill() {
return (
<p className="max-w-md text-[15px] leading-8 text-foreground">
The team plan costs $12 per seat a month on yearly billing
<Citation variant="pill" sources={pricing} />, and the free tier now includes three projects
<Citation variant="pill" sources={pricing[1]!} />.
</p>
)
}API reference
<SourceFavicon>
| Prop | Type | Default |
|---|---|---|
source* | Pick<CitationSource, "url" | "site" | "favicon"> | — |
<Citation>
| Prop | Type | Default |
|---|---|---|
sources*The source, or several sources backing one claim. With several, the card gets a pager. | CitationSource | CitationSource[] | — |
indexNumber shown in the marker. Ignored by the "pill" style. | number | 1 |
variant"number" is a small superscript-style chip like [1]. "pill" shows the site name with a +N count. | "number" | "pill" | "number" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Focuses the marker and opens its card |
| Enter | Opens the source in a new tab |
| Screen readers | Named 'Source 1: Title', with 'and 2 more' for grouped sources |
Use with AI
Place <Citation index={1} sources={{ title, url, snippet }} /> right after the claim. Pass an array for several sources. variant is number | pill. The marker is a real link, so touch users open the page directly. Also exports SourceFavicon and hostOf. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Citation (@ballmac/citation) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Answers grounded in web or document sources
- Footnotes that should not pull the reader away from the text
Not for
- A full list of references; use sources-list
- Tooltips with no link; use tooltip
Registry JSON: https://ui.ballmac.com/r/citation.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
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.
Hover Card
A compact preview of a linked resource that appears on hover or keyboard focus without hiding essential information.
Agent Plan
A live task plan for agents: a vertical timeline with pending, running, done, failed and skipped steps, nested substeps, expandable output, segmented progress and retry on failure.