text
Sources Stack
An overlapping stack of source favicons with a count, expanding into a linked source list.
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { ChevronDown, ExternalLink, Globe } from "lucide-react";
import { cn } from "@/lib/utils";
export interface Source {
title: string;
domain: string;
url: string;
/** Falls back to a globe icon when omitted or when the image fails to load. */
faviconUrl?: string;
}
export interface SourcesStackProps {
sources: Source[];
/** How many favicons render in the collapsed stack before folding into the count. */
visibleCount?: number;
defaultOpen?: boolean;
className?: string;
}
export function SourcesStack({
sources,
visibleCount = 3,
defaultOpen = false,
className,
}: SourcesStackProps) {
const [open, setOpen] = React.useState(defaultOpen);
const listId = React.useId();
const stacked = sources.slice(0, visibleCount);
return (
<div className={cn("w-full max-w-xs", className)}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={listId}
className="flex w-full items-center gap-2 rounded-full py-1.5 pl-1.5 pr-3 transition-colors hover:bg-accent/50"
>
<span aria-hidden className="flex -space-x-2">
{stacked.map((source, i) => (
<Favicon key={source.url} source={source} style={{ zIndex: stacked.length - i }} />
))}
</span>
<span className="text-xs text-muted-foreground">
{sources.length} source{sources.length === 1 ? "" : "s"}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform duration-200",
open && "rotate-180"
)}
/>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.ul
id={listId}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="mt-1.5 overflow-hidden rounded-xl border bg-popover"
>
{sources.map((source) => (
<li key={source.url} className="border-b last:border-b-0">
<a
href={source.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2.5 px-3 py-2 transition-colors hover:bg-accent/50"
>
<Favicon source={source} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground/90">{source.title}</span>
<span className="block truncate text-xs text-muted-foreground">{source.domain}</span>
</span>
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
</a>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
);
}
function Favicon({ source, style }: { source: Source; style?: React.CSSProperties }) {
const [errored, setErrored] = React.useState(false);
const showImage = source.faviconUrl && !errored;
return (
<span
style={style}
className="relative flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-background bg-muted"
>
{showImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={source.faviconUrl} alt="" className="size-full object-cover" onError={() => setErrored(true)} />
) : (
<Globe className="size-2.5 text-muted-foreground" aria-hidden />
)}
</span>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Sources Stack
## Summary
A compact "N sources" pill showing overlapping favicons for the sources behind an answer, which expands in place into a linked list of every source. It's the answer-level counterpart to Inline Citation's claim-level footnote — one glance at the total, one tap for the full list.
## When to use
- Summarizing all the sources an answer drew on, in a single, low-footprint control rather than a dedicated section that always takes up space.
- The exact source count and provenance (which domains) matter to the user's trust in the answer, but the individual claim-to-source mapping doesn't need to be shown inline.
- After a research or search-heavy answer where listing every source inline (via Inline Citation) would be excessive, but omitting sources entirely would undersell the work done.
## When not to use
- When a specific sentence needs to point at a specific source — use Inline Citation for that claim-level link; this pattern only summarizes the whole set.
- For a single source. A stack implies plural; one source reads better as a plain link or a single favicon + domain label.
- As a replacement for inline citations in a long, multi-claim answer — pair the two rather than picking one over the other.
## Anatomy
- Collapsed trigger: a pill with a stack of overlapping favicons (2-3 visible, each ringed to separate it from the one behind) followed by the total count ("7 sources") and a chevron.
- Expanded list: one row per source, each with its favicon, title, domain, and an external-link affordance, opening in a new tab.
## Behavior
- Clicking the pill toggles the list open/closed in place, pushing surrounding content rather than overlaying it.
- The favicon stack always shows the same leading few sources regardless of how many are open in the list — it's a preview, not a paginated view.
- A source with no reachable favicon falls back to a generic globe icon rather than a broken image.
- The chevron rotates to reflect open/closed state; the transition animates height, not just opacity, so surrounding layout doesn't jump.
## Content guidelines
- Titles are the source's own page title, not a paraphrase; domains are the bare hostname, no protocol or path.
- Order sources by relevance or citation order, not alphabetically — the first favicon in the stack should be the most load-bearing source.
## Accessibility
- The trigger is a real `<button>` with `aria-expanded` and `aria-controls` pointing at the list.
- The favicon stack in the collapsed trigger is decorative (`aria-hidden`) since the count text already states how many sources there are.
- Each expanded row is a real `<a>` to the source, reachable and activatable by keyboard alone.
## Related patterns
- Inline Citation is the claim-level counterpart — a numbered marker tied to one sentence, versus this pattern's answer-level summary of every source used.