{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "citations",
  "type": "registry:ui",
  "title": "Citations",
  "description": "Numbered source pills that preview on hover and light their list row.",
  "categories": [
    "ai-chat"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/citations.tsx",
      "content": "\"use client\";\nimport { PreviewCard } from \"@base-ui/react/preview-card\";\nimport { animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { createContext, isValidElement, useContext, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUpRight, ChevronDown } from \"@/lib/icons\";\nimport { spring } from \"@/lib/motion\";\n\n/* -------------------------------------------------------------------------------------------------\n * Data and context\n * -----------------------------------------------------------------------------------------------*/\n\nexport type CitationSource = {\n  id: string;\n  url: string;\n  title: string;\n  /** The passage the claim rests on. Keep it short; it's clamped to three lines. */\n  snippet?: string;\n  /** A URL, or your own node. Falls back to the site's first letter. */\n  favicon?: string | React.ReactNode;\n  /** Shown instead of the host when given, e.g. \"Postgres docs\". */\n  siteName?: string;\n  /** Preformatted, e.g. \"12 Mar 2026\" or \"Updated 3d ago\". */\n  meta?: string;\n};\n\ntype Active = { ids: string[]; from: \"inline\" | \"list\" } | null;\n\ntype Ctx = {\n  sources: CitationSource[];\n  byId: Map<string, { source: CitationSource; n: number }>;\n  active: Active;\n  setActive: (a: Active) => void;\n  listId: string;\n};\n\nconst CitationsContext = createContext<Ctx | null>(null);\n\nfunction useCitations() {\n  const ctx = useContext(CitationsContext);\n  if (!ctx) throw new Error(\"Citation parts must be inside <Citations>\");\n  return ctx;\n}\n\nconst hostOf = (url: string) => {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    return url;\n  }\n};\n\nexport type CitationsProps = React.ComponentProps<\"div\"> & {\n  /** Every source the reply cites, in the order they're numbered. */\n  sources: CitationSource[];\n};\n\n/** Numbers the sources and links each inline marker to its row in the list, both ways. */\nexport function Citations({ sources, className, children, ...rest }: CitationsProps) {\n  const [active, setActive] = useState<Active>(null);\n  const listId = useId();\n  const byId = useMemo(() => new Map(sources.map((s, i) => [s.id, { source: s, n: i + 1 }])), [sources]);\n  return (\n    <CitationsContext value={{ sources, byId, active, setActive, listId }}>\n      <div className={cn(\"min-w-0\", className)} {...rest}>\n        {children}\n      </div>\n    </CitationsContext>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Inline marker\n * -----------------------------------------------------------------------------------------------*/\n\nexport type CitationProps = Omit<PreviewCard.Trigger.Props, \"className\" | \"children\" | \"id\"> & {\n  /** One source id, or several when a claim rests on more than one. */\n  id: string | string[];\n  className?: string;\n};\n\n/**\n * A numbered pill inside the text. Hover or focus previews the source; pressing opens it.\n * Several ids show the first number and a count, and the preview lists each.\n */\nexport function Citation({ id, className, delay = 250, closeDelay = 120, ...rest }: CitationProps) {\n  const { byId, active, setActive } = useCitations();\n  const ids = (Array.isArray(id) ? id : [id]).filter((x) => byId.has(x));\n  if (ids.length === 0) return null; // No source, no claim of one.\n  const entries = ids.map((x) => byId.get(x)!);\n  const first = entries[0];\n  const lit = active?.from === \"list\" && active.ids.some((x) => ids.includes(x));\n  const label =\n    entries.length === 1\n      ? `Source ${first.n}: ${first.source.title} (opens in a new tab)`\n      : `Sources ${entries.map((e) => e.n).join(\", \")}: ${entries.map((e) => e.source.title).join(\"; \")} (opens in a new tab)`;\n\n  return (\n    <PreviewCard.Root>\n      <PreviewCard.Trigger\n        href={first.source.url}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        aria-label={label}\n        delay={delay}\n        closeDelay={closeDelay}\n        data-active={lit ? \"\" : undefined}\n        onPointerEnter={() => setActive({ ids, from: \"inline\" })}\n        onPointerLeave={() => setActive(null)}\n        onFocus={() => setActive({ ids, from: \"inline\" })}\n        onBlur={() => setActive(null)}\n        className={cn(\n          // Sits on the baseline like a superscript would, without changing the line height.\n          \"relative mx-[0.15em] inline-flex h-[17px] min-w-[17px] -translate-y-px select-none items-center justify-center gap-0.5 rounded-full px-[5px] align-[0.08em]\",\n          \"border border-line-2 bg-raised font-mono text-[10.5px] font-medium leading-none tabular text-fg-2 no-underline\",\n          \"outline-none transition-[background-color,border-color,color,scale] duration-150 ease-out active:scale-[0.92] active:duration-75\",\n          \"hover:border-fg hover:bg-fg hover:text-frame data-popup-open:border-fg data-popup-open:bg-fg data-popup-open:text-frame\",\n          \"data-active:border-fg data-active:bg-fg data-active:text-frame\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          // A touch target of 44px around a 17px pill.\n          \"before:absolute before:-inset-3.5 before:content-[''] pointer-fine:before:hidden motion-reduce:active:scale-100\",\n          className,\n        )}\n        {...rest}\n      >\n        {first.n}\n        {entries.length > 1 && <span className=\"text-[9.5px] opacity-70\">+{entries.length - 1}</span>}\n      </PreviewCard.Trigger>\n      <PreviewCard.Portal>\n        <PreviewCard.Positioner side=\"top\" align=\"center\" sideOffset={8} collisionPadding={12} className=\"z-(--z-popover)\">\n          <PreviewCard.Popup\n            className={cn(\n              \"w-[min(20rem,calc(100vw-24px))] origin-(--transform-origin) overflow-hidden rounded-xl border border-line-2 bg-raised text-left text-fg shadow-pop outline-none\",\n              \"transition-[opacity,scale,translate] duration-160 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0\",\n              \"data-[side=top]:data-starting-style:translate-y-1 data-[side=bottom]:data-starting-style:-translate-y-1\",\n              \"data-ending-style:opacity-0 data-ending-style:duration-100 data-instant:transition-none\",\n              \"motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:translate-y-0\",\n            )}\n          >\n            <div className=\"divide-y divide-line\">\n              {entries.slice(0, 3).map(({ source, n }) => (\n                <SourcePreview key={source.id} source={source} n={n} compact={entries.length > 1} />\n              ))}\n            </div>\n            {entries.length > 3 && <p className=\"border-t border-line px-3 py-2 text-[11.5px] text-fg-3\">and {entries.length - 3} more below</p>}\n          </PreviewCard.Popup>\n        </PreviewCard.Positioner>\n      </PreviewCard.Portal>\n    </PreviewCard.Root>\n  );\n}\n\nfunction SourcePreview({ source, n, compact }: { source: CitationSource; n: number; compact: boolean }) {\n  const host = hostOf(source.url);\n  return (\n    <div className=\"flex flex-col gap-1.5 p-3\">\n      <div className=\"flex min-w-0 items-center gap-2 text-[11.5px] text-fg-3\">\n        <Favicon src={source.favicon} host={host} />\n        <span className=\"min-w-0 truncate text-fg-2\">{source.siteName ?? host}</span>\n        {source.meta && (\n          <>\n            <span aria-hidden className=\"text-fg-4\">·</span>\n            <span className=\"shrink-0 text-fg-3\">{source.meta}</span>\n          </>\n        )}\n        <span className=\"ms-auto shrink-0 font-mono text-[10.5px] text-fg-4 tabular\">[{n}]</span>\n      </div>\n      <p className={cn(\"text-[13px] font-medium leading-[1.35] tracking-[-0.01em] text-fg\", compact ? \"line-clamp-1\" : \"line-clamp-2\")}>{source.title}</p>\n      {source.snippet && (\n        <p className={cn(\"border-l-2 border-line-2 pl-2 text-[12px] leading-[1.5] text-fg-2\", compact ? \"line-clamp-2\" : \"line-clamp-3\")}>{source.snippet}</p>\n      )}\n    </div>\n  );\n}\n\nfunction Favicon({ src, host, size = 16 }: { src?: CitationSource[\"favicon\"]; host: string; size?: number }) {\n  const [failed, setFailed] = useState<string | null>(null);\n  const box = \"grid shrink-0 place-items-center overflow-hidden rounded-[4px]\";\n  if (isValidElement(src))\n    return (\n      <span aria-hidden className={cn(box, \"text-fg\")} style={{ width: size, height: size }}>\n        {src}\n      </span>\n    );\n  if (typeof src === \"string\" && src && failed !== src)\n    // eslint-disable-next-line @next/next/no-img-element -- favicons come from any host\n    return <img src={src} alt=\"\" width={size} height={size} onError={() => setFailed(src)} className={box} />;\n  // No icon, or it failed: the site's first letter on a quiet tile, the same size as the icon.\n  return (\n    <span\n      aria-hidden\n      className={cn(box, \"border border-line-2 bg-hover font-medium uppercase leading-none text-fg-2\")}\n      style={{ width: size, height: size, fontSize: Math.round(size * 0.6) }}\n    >\n      {host.charAt(0)}\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Sources list\n * -----------------------------------------------------------------------------------------------*/\n\nexport type CitationSourcesProps = Omit<React.ComponentProps<\"section\">, \"children\"> & {\n  /** Rows shown before \"Show more\". Everything shows when there are at most this many plus one. */\n  visible?: number;\n  title?: string;\n};\n\nexport function CitationSources({ visible = 3, title = \"Sources\", className, ...rest }: CitationSourcesProps) {\n  const { sources, active, setActive, listId } = useCitations();\n  const reduce = !!useReducedMotion();\n  const [open, setOpen] = useState(false);\n  const list = useRef<HTMLDivElement>(null);\n  const y = useMotionValue(0);\n  const height = useMotionValue(36);\n  const opacity = useMotionValue(0);\n  const shown = useRef(false);\n\n  // Hiding a single row behind a button costs more than showing it.\n  const collapsible = sources.length > visible + 1;\n  const head = collapsible ? sources.slice(0, visible) : sources;\n  const tail = collapsible ? sources.slice(visible) : [];\n  const headingId = `${listId}-title`;\n\n  // One highlight for the list, glided to whichever row is active, whether the pointer is on the\n  // row or on its marker up in the text.\n  const activeId = active?.ids[0];\n  useEffect(() => {\n    const el = list.current;\n    if (!el) return;\n    const row = activeId ? el.querySelector<HTMLElement>(`[data-source=\"${CSS.escape(activeId)}\"]`) : null;\n    const visibleRow = row && row.offsetParent !== null && row.getClientRects().length > 0 && !row.closest(\"[inert]\") ? row : null;\n    if (!visibleRow) {\n      animate(opacity, 0, { duration: reduce ? 0 : 0.12 });\n      shown.current = false;\n      return;\n    }\n    const top = visibleRow.offsetTop;\n    if (!shown.current || reduce) {\n      y.jump(top);\n      height.jump(visibleRow.offsetHeight);\n    } else {\n      animate(y, top, spring.follow);\n      animate(height, visibleRow.offsetHeight, spring.follow);\n    }\n    animate(opacity, 1, { duration: reduce ? 0 : 0.1 });\n    shown.current = true;\n  }, [activeId, open, reduce, y, height, opacity]);\n\n  if (sources.length === 0) return null;\n\n  const row = (s: CitationSource, i: number) => {\n    const host = hostOf(s.url);\n    const lit = !!active && active.ids.includes(s.id);\n    return (\n      <li key={s.id} data-source={s.id}>\n        <a\n          href={s.url}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          data-active={lit ? \"\" : undefined}\n          onPointerEnter={() => setActive({ ids: [s.id], from: \"list\" })}\n          onPointerLeave={() => setActive(null)}\n          onFocus={() => setActive({ ids: [s.id], from: \"list\" })}\n          onBlur={() => setActive(null)}\n          className={cn(\n            \"group/row relative flex h-9 min-w-0 items-center gap-2.5 rounded-lg px-2 text-[12.5px] no-underline outline-none\",\n            \"transition-[scale] duration-100 active:scale-[0.99] focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n          )}\n        >\n          <span\n            className={cn(\n              \"grid h-4 min-w-4 shrink-0 place-items-center rounded-full border border-line-2 px-1 font-mono text-[10px] font-medium leading-none tabular text-fg-3\",\n              \"transition-[background-color,border-color,color] duration-150 group-data-active/row:border-fg group-data-active/row:bg-fg group-data-active/row:text-frame\",\n            )}\n          >\n            {i + 1}\n          </span>\n          <Favicon src={s.favicon} host={host} />\n          <span className=\"min-w-0 flex-1 truncate text-fg\">{s.title}</span>\n          <span className=\"hidden max-w-[40%] shrink-0 truncate text-fg-3 sm:block\">{s.siteName ?? host}</span>\n          <ArrowUpRight\n            size={14}\n            className=\"shrink-0 text-fg-4 transition-[color,translate] duration-150 ease-out group-data-active/row:translate-x-px group-data-active/row:-translate-y-px group-data-active/row:text-fg-2\"\n          />\n          <span className=\"sr-only\">(opens in a new tab)</span>\n        </a>\n      </li>\n    );\n  };\n\n  return (\n    <section aria-labelledby={headingId} className={cn(\"min-w-0\", className)} {...rest}>\n      <h3 id={headingId} className=\"mb-1.5 flex items-center gap-1.5 px-2 font-mono text-[10.5px] font-medium uppercase tracking-[0.08em] text-fg-3\">\n        {title}\n        <span className=\"text-fg-4 tabular\">{sources.length}</span>\n      </h3>\n      <div ref={list} className=\"relative isolate\">\n        <motion.span aria-hidden style={{ y, height, opacity }} className=\"pointer-events-none absolute inset-x-0 top-0 -z-10 rounded-lg bg-fg/[0.05]\" />\n        <ol className=\"flex flex-col\">{head.map((s, i) => row(s, i))}</ol>\n        {collapsible && (\n          <div\n            // Grows to its content with grid rows, so nothing is measured and nothing jumps.\n            className={cn(\n              \"grid transition-[grid-template-rows,opacity] duration-240 ease-in-out-quart motion-reduce:transition-none\",\n              open ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\",\n            )}\n            inert={!open}\n          >\n            <ol start={visible + 1} className=\"flex min-h-0 flex-col overflow-hidden\">\n              {tail.map((s, i) => row(s, visible + i))}\n            </ol>\n          </div>\n        )}\n      </div>\n      {collapsible && (\n        <button\n          type=\"button\"\n          aria-expanded={open}\n          onClick={() => setOpen((o) => !o)}\n          className={cn(\n            \"mt-0.5 inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-fg-3 outline-none\",\n            \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.97]\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n          )}\n        >\n          {!open && (\n            <span className=\"flex -space-x-0.5\" aria-hidden>\n              {tail.slice(0, 3).map((s) => (\n                <span key={s.id} className=\"rounded-[5px] ring-2 ring-frame\">\n                  <Favicon src={s.favicon} host={hostOf(s.url)} size={14} />\n                </span>\n              ))}\n            </span>\n          )}\n          {open ? \"Show fewer\" : `Show ${tail.length} more`}\n          <ChevronDown size={14} className={cn(\"transition-transform duration-200 ease-out-expo\", open && \"rotate-180\")} />\n        </button>\n      )}\n    </section>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/citations"
}
