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.
- Polar Data CenterMar 4, 2026Source 1: How sea ice extent is measured from satellites
- Example NewsSep 21, 2026Source 2: Arctic summer minimum reaches the second lowest on record
- Journal of Polar ScienceSource 3: Changes in multi-year ice since 1985
- Northern UniversitySource 4: Sea ice and coastal communities
to highlight source 2.
"use client"
import * as React from "react"
import { SourcesList } from "@/components/ballmac/sources-list"
import 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" },
{ title: "Arctic summer minimum reaches the second lowest on record", url: "https://example.com/news/arctic-minimum", site: "Example News", date: "Sep 21, 2026" },
{ title: "Changes in multi-year ice since 1985", url: "https://example.net/research/multiyear-ice", site: "Journal of Polar Science" },
{ title: "Sea ice and coastal communities", url: "https://example.edu/arctic/communities", site: "Northern University" },
{ title: "Monthly extent series, 1979 to today", url: "https://example.gov/data/sea-ice-index", site: "National Ice Archive" },
{ title: "Explainer: why ice loss speeds up warming", url: "https://example.org/explainers/albedo", site: "Climate Explained" },
]
export default function SourcesListDemo() {
const [hover, setHover] = React.useState<number | null>(null)
return (
<div className="w-full max-w-md" onMouseLeave={() => setHover(null)}>
<SourcesList sources={sources} defaultOpen highlight={hover} visibleCount={4} />
<p className="mt-2 px-1 text-xs text-muted-foreground">
<button
type="button"
onFocus={() => setHover(2)}
onBlur={() => setHover(null)}
onMouseEnter={() => setHover(2)}
className="rounded-sm underline underline-offset-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Hover or focus this
</button>{" "}
to highlight source 2.
</p>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/sources-listInstall the dependencies.
$ pnpm add motion@^12 lucide-react radix-uiAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/citation @ballmac/i18nCopy the source into your project.
components/ballmac/sources-list.tsx// Ballmac UI: Sources List. https://ui.ballmac.com/components/sources-list "use client" import * as React from "react" import { ChevronDown } from "lucide-react" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { Collapsible as CollapsiblePrimitive } from "radix-ui" import { SourceFavicon, hostOf, type CitationSource } from "@/components/ballmac/citation" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type SourcesListProps = Omit<React.ComponentProps<"div">, "onChange" | "title"> & { /** The pages the answer drew on, in citation order: the first is [1]. */ sources: CitationSource[] /** "list" is one compact row per source, "cards" is a grid with excerpts. */ variant?: "list" | "cards" /** Heading. The count is added for you. */ title?: string /** Controlled open state. */ open?: boolean /** Initial open state when uncontrolled. */ defaultOpen?: boolean /** Called when the list opens or closes. */ onOpenChange?: (open: boolean) => void /** Turn off the disclosure and always show the list. */ collapsible?: boolean /** How many sources show before "Show all". */ visibleCount?: number /** One-based number of the source to emphasize, for example while its citation is hovered. */ highlight?: number | null /** Id prefix for each source row (`<idPrefix>-1`). Lets a citation link scroll to its row. */ idPrefix?: string } function SourcesList({ sources, variant = "list", title, open: openProp, defaultOpen = false, onOpenChange, collapsible = true, visibleCount = 4, highlight = null, idPrefix, className, ...props }: SourcesListProps) { const msg = useMessages() title ??= msg("sources-list.title", "Sources") const reduce = useReducedMotion() const autoId = React.useId() const prefix = idPrefix ?? `sources${autoId.replace(/:/g, "")}` const [internalOpen, setInternalOpen] = React.useState(defaultOpen) const open = collapsible ? (openProp ?? internalOpen) : true const [all, setAll] = React.useState(false) const shown = all ? sources : sources.slice(0, visibleCount) const hidden = sources.length - shown.length function setOpen(next: boolean) { setInternalOpen(next) onOpenChange?.(next) } const count = `${sources.length} ${sources.length === 1 ? "source" : "sources"}` const rows = ( <ol aria-label={title} className={cn(variant === "cards" ? "grid gap-2 sm:grid-cols-2" : "grid gap-0.5")} > <AnimatePresence initial={false}> {shown.map((source, i) => { const n = i + 1 const active = highlight === n return ( <motion.li key={source.url} layout={reduce ? false : "position"} initial={reduce ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} transition={{ duration: 0.2, delay: reduce ? 0 : Math.min(Math.max(i - visibleCount, 0), 6) * 0.03 }} className="min-w-0" > <a id={`${prefix}-${n}`} href={source.url} target="_blank" rel="noreferrer noopener" data-active={active || undefined} className={cn( "group/source flex min-w-0 gap-3 rounded-lg outline-none transition-[background-color,box-shadow] duration-150 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none", variant === "cards" ? "h-full border bg-card p-3 hover:border-foreground/25 hover:bg-accent/50" : "items-center px-2.5 py-2 hover:bg-accent/70", active && "bg-accent ring-1 ring-foreground/25" )} > <span aria-hidden="true" className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-md bg-muted px-1 font-mono text-[11px] text-muted-foreground tabular-nums" > {n} </span> <span className="grid min-w-0 flex-1 gap-0.5"> <span className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"> <SourceFavicon source={source} className="size-4 text-[9px]" /> <span className="truncate">{source.site ?? hostOf(source.url)}</span> {source.date && <span className="shrink-0 before:me-1.5 before:content-['·']">{source.date}</span>} </span> <span className={cn( "text-sm font-medium text-foreground group-hover/source:underline group-hover/source:underline-offset-4", variant === "cards" ? "line-clamp-2" : "truncate" )} > <span className="sr-only">{`Source ${n}:`}</span>{" "} {source.title} </span> {variant === "cards" && source.snippet && ( <span className="line-clamp-2 text-[13px] leading-5 text-muted-foreground">{source.snippet}</span> )} </span> </a> </motion.li> ) })} </AnimatePresence> </ol> ) const more = hidden > 0 && ( <button type="button" onClick={() => setAll(true)} className="mt-1 ms-2 inline-flex h-8 items-center rounded-md px-2 text-[13px] font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50" > {msg("sources-list.showAll", "Show all")} {sources.length} </button> ) if (!collapsible) { return ( <div data-slot="sources-list" className={cn("w-full", className)} {...props}> <p className="mb-1.5 px-2.5 text-sm font-medium text-foreground"> {title} <span className="font-normal text-muted-foreground tabular-nums">{sources.length}</span> </p> {rows} {more} </div> ) } const stack = sources.slice(0, 4) return ( <CollapsiblePrimitive.Root data-slot="sources-list" open={open} onOpenChange={setOpen} className={cn("w-full rounded-xl border bg-card text-card-foreground", className)} {...props} > <CollapsiblePrimitive.Trigger data-slot="sources-trigger" className="group flex min-h-11 w-full items-center gap-3 rounded-xl px-3 py-2 text-start text-sm outline-none transition-colors duration-150 hover:bg-accent/50 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none" > <span aria-hidden="true" className="flex -space-x-1.5"> {stack.map((s, i) => ( <SourceFavicon key={s.url} source={s} className="size-6 rounded-full border-2 border-card text-[10px]" style={{ zIndex: stack.length - i }} /> ))} </span> <span className="font-medium text-foreground">{title}</span> <span className="text-muted-foreground tabular-nums">{count}</span> <ChevronDown aria-hidden="true" className="ms-auto size-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180 motion-reduce:transition-none" /> </CollapsiblePrimitive.Trigger> <CollapsiblePrimitive.Content className="overflow-hidden data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 motion-reduce:animate-none"> <div className="border-t p-1.5"> {rows} {more} </div> </CollapsiblePrimitive.Content> </CollapsiblePrimitive.Root> ) } export { SourcesList, type SourcesListProps }Update the import paths to match your project setup.
Usage
import { SourcesList } from "@/components/ballmac/sources-list"The full example is in the Code tab above.
Examples
Always-open cards
Read more 5
- Polar Data CenterMar 4, 2026Source 1: How sea ice extent is measured from satellitesPassive microwave sensors record the surface every day; extent counts areas with 15% ice or more.
- Example NewsSep 21, 2026Source 2: Arctic summer minimum reaches the second lowest on recordThe yearly minimum fell well below the 1981–2010 average.
- Journal of Polar ScienceSource 3: Changes in multi-year ice since 1985Older, thicker ice now covers a fraction of what it did four decades ago.
- Climate ExplainedSource 4: Explainer: why ice loss speeds up warmingDark open water absorbs far more sunlight than bright ice.
import { SourcesList } from "@/components/ballmac/sources-list"
export default function SourcesListCards() {
return (
<div className="w-full max-w-xl">
<SourcesList
variant="cards"
collapsible={false}
title="Read more"
visibleCount={4}
sources={[
{ 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 surface every day; extent counts areas with 15% ice or more." },
{ 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." },
{ title: "Changes in multi-year ice since 1985", url: "https://example.net/research/multiyear-ice", site: "Journal of Polar Science", snippet: "Older, thicker ice now covers a fraction of what it did four decades ago." },
{ title: "Explainer: why ice loss speeds up warming", url: "https://example.org/explainers/albedo", site: "Climate Explained", snippet: "Dark open water absorbs far more sunlight than bright ice." },
{ title: "Monthly extent series, 1979 to today", url: "https://example.gov/data/sea-ice-index", site: "National Ice Archive" },
]}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
sources*The pages the answer drew on, in citation order: the first is [1]. | CitationSource[] | — |
variant"list" is one compact row per source, "cards" is a grid with excerpts. | "list" | "cards" | "list" |
titleHeading. The count is added for you. | string | — |
openControlled open state. | boolean | — |
defaultOpenInitial open state when uncontrolled. | boolean | false |
onOpenChangeCalled when the list opens or closes. | (open: boolean) => void | — |
collapsibleTurn off the disclosure and always show the list. | boolean | true |
visibleCountHow many sources show before "Show all". | number | 4 |
highlightOne-based number of the source to emphasize, for example while its citation is hovered. | number | null | null |
idPrefixId prefix for each source row (`<idPrefix>-1`). Lets a citation link scroll to its row. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space | Opens or closes the list from its header |
| Tab | Moves through the source links |
| Screen readers | An ordered list named Sources; each link reads 'Source 2: Title' |
Use with AI
sources is the array used by citations, in order. variant is list | cards. collapsible={false} keeps it open. highlight={n} emphasizes row n, and each row has id `${idPrefix}-${n}` so a citation can link to it. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Sources List (@ballmac/sources-list) to this project with the shadcn MCP, then use it where it fits.
Use it for
- The end of a grounded answer
- A side panel of everything the assistant read
Not for
- Inline markers; use citation
- Generic link lists
Registry JSON: https://ui.ballmac.com/r/sources-list.json
Credits
Free to use in personal and commercial projects.
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.
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.
Collapsible
A single disclosure panel with a generous trigger, visible open state, and reduced-motion-aware content transition.
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.