{"slug":"sources-stack","category":"text","job":"trust","added":"2026-09-12T07:50Z","title":"Sources Stack","description":"An overlapping stack of source favicons with a count, expanding into a linked source list.","url":"https://ai-patterns.app/patterns/text/sources-stack","markdown":"https://ai-patterns.app/patterns/text/sources-stack.md","json":"https://ai-patterns.app/api/patterns/text/sources-stack","seenIn":[{"team":"chatgpt","source":"https://help.openai.com/en/articles/9237897-chatgpt-search"},{"team":"Notion","source":"https://www.notion.com/help/enterprise-search"}],"spec":"# Sources Stack\n\n## Summary\nA 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.\n\n## When to use\n- Summarizing all the sources an answer drew on, in a single, low-footprint control rather than a dedicated section that always takes up space.\n- 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.\n- 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.\n\n## When not to use\n- 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.\n- For a single source. A stack implies plural; one source reads better as a plain link or a single favicon + domain label.\n- As a replacement for inline citations in a long, multi-claim answer — pair the two rather than picking one over the other.\n\n## Anatomy\n- 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.\n- Expanded list: one row per source, each with its favicon, title, domain, and an external-link affordance, opening in a new tab.\n\n## Behavior\n- Clicking the pill toggles the list open/closed in place, pushing surrounding content rather than overlaying it.\n- 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.\n- A source with no reachable favicon falls back to a generic globe icon rather than a broken image.\n- The chevron rotates to reflect open/closed state; the transition animates height, not just opacity, so surrounding layout doesn't jump.\n\n## Content guidelines\n- Titles are the source's own page title, not a paraphrase; domains are the bare hostname, no protocol or path.\n- Order sources by relevance or citation order, not alphabetically — the first favicon in the stack should be the most load-bearing source.\n\n## Accessibility\n- The trigger is a real `<button>` with `aria-expanded` and `aria-controls` pointing at the list.\n- The favicon stack in the collapsed trigger is decorative (`aria-hidden`) since the count text already states how many sources there are.\n- Each expanded row is a real `<a>` to the source, reachable and activatable by keyboard alone.\n\n## Related patterns\n- **Inline Citation** — Use it as well to tie a numbered marker to the one sentence a source supports.\n- **Source Trust Card** — Use it instead when one source needs a full preview and trust signals.\n- **Collaborative Presence** — Use it as a visual reference, since it uses the same overlapping circles for people.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { ChevronDown, ExternalLink, Globe } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface Source {\n  title: string;\n  domain: string;\n  url: string;\n  /** Falls back to a globe icon when omitted or when the image fails to load. */\n  faviconUrl?: string;\n}\n\nexport interface SourcesStackProps {\n  sources: Source[];\n  /** How many favicons render in the collapsed stack before folding into the count. */\n  visibleCount?: number;\n  defaultOpen?: boolean;\n  className?: string;\n}\n\nexport function SourcesStack({\n  sources,\n  visibleCount = 3,\n  defaultOpen = false,\n  className,\n}: SourcesStackProps) {\n  const [open, setOpen] = React.useState(defaultOpen);\n  const listId = React.useId();\n  const stacked = sources.slice(0, visibleCount);\n\n  return (\n    <div className={cn(\"w-full max-w-xs\", className)}>\n      <button\n        type=\"button\"\n        onClick={() => setOpen((v) => !v)}\n        aria-expanded={open}\n        aria-controls={listId}\n        className=\"flex w-full items-center gap-2 rounded-full py-1.5 pl-1.5 pr-3 transition-colors hover:bg-accent/50\"\n      >\n        <span aria-hidden className=\"flex -space-x-2\">\n          {stacked.map((source, i) => (\n            <Favicon key={source.url} source={source} style={{ zIndex: stacked.length - i }} />\n          ))}\n        </span>\n        <span className=\"text-xs text-muted-foreground\">\n          {sources.length} source{sources.length === 1 ? \"\" : \"s\"}\n        </span>\n        <ChevronDown\n          className={cn(\n            \"size-3.5 shrink-0 text-muted-foreground transition-transform duration-200\",\n            open && \"rotate-180\"\n          )}\n        />\n      </button>\n\n      <AnimatePresence initial={false}>\n        {open && (\n          <motion.ul\n            id={listId}\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0 }}\n            transition={{ duration: 0.2, ease: \"easeInOut\" }}\n            className=\"mt-1.5 overflow-hidden rounded-xl border bg-popover\"\n          >\n            {sources.map((source) => (\n              <li key={source.url} className=\"border-b last:border-b-0\">\n                <a\n                  href={source.url}\n                  target=\"_blank\"\n                  rel=\"noreferrer\"\n                  className=\"flex items-center gap-2.5 px-3 py-2 transition-colors hover:bg-accent/50\"\n                >\n                  <Favicon source={source} />\n                  <span className=\"min-w-0 flex-1\">\n                    <span className=\"block truncate text-sm text-foreground/90\">{source.title}</span>\n                    <span className=\"block truncate text-xs text-muted-foreground\">{source.domain}</span>\n                  </span>\n                  <ExternalLink className=\"size-3.5 shrink-0 text-muted-foreground\" aria-hidden />\n                </a>\n              </li>\n            ))}\n          </motion.ul>\n        )}\n      </AnimatePresence>\n    </div>\n  );\n}\n\nfunction Favicon({ source, style }: { source: Source; style?: React.CSSProperties }) {\n  const [errored, setErrored] = React.useState(false);\n  const showImage = source.faviconUrl && !errored;\n\n  return (\n    <span\n      style={style}\n      className=\"relative flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-background bg-muted\"\n    >\n      {showImage ? (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img src={source.faviconUrl} alt=\"\" className=\"size-full object-cover\" onError={() => setErrored(true)} />\n      ) : (\n        <Globe className=\"size-2.5 text-muted-foreground\" aria-hidden />\n      )}\n    </span>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}