{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "citation",
  "title": "Citation",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "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.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/hover-card.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/citation.tsx",
      "content": "// Ballmac UI: Citation. https://ui.ballmac.com/components/citation\n\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight, ExternalLink } from \"lucide-react\"\n\nimport { HoverCard, HoverCardContent, HoverCardTrigger } from \"@/components/ballmac/hover-card\"\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype CitationSource = {\n  /** Page title. */\n  title: string\n  /** Link to the page. */\n  url: string\n  /** Short excerpt shown in the preview card. */\n  snippet?: string\n  /** Shown instead of the host name, for example a publisher. */\n  site?: string\n  /** Icon image URL. When omitted a colored monogram is drawn, so nothing is fetched from a third party. */\n  favicon?: string\n  /** Publication date as text, for example \"Mar 4, 2026\". */\n  date?: string\n}\n\nconst TONES = [\"bg-chart-1\", \"bg-chart-2\", \"bg-chart-3\", \"bg-chart-4\", \"bg-chart-5\"]\n\nfunction hostOf(url: string) {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\")\n  } catch {\n    return url\n  }\n}\n\nfunction hashOf(text: string) {\n  let h = 0\n  for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0\n  return h\n}\n\ntype SourceFaviconProps = React.ComponentProps<\"span\"> & {\n  source: Pick<CitationSource, \"url\" | \"site\" | \"favicon\">\n}\n\n/** A small rounded icon for a source: its favicon when given, otherwise the first letter of its site on a stable color. */\nfunction SourceFavicon({ source, className, ...props }: SourceFaviconProps) {\n  const name = source.site ?? hostOf(source.url)\n  return (\n    <span\n      data-slot=\"source-favicon\"\n      aria-hidden=\"true\"\n      className={cn(\n        \"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\",\n        !source.favicon && TONES[hashOf(name) % TONES.length],\n        className\n      )}\n      {...props}\n    >\n      {source.favicon ? (\n        // eslint-disable-next-line @next/next/no-img-element\n        <img src={source.favicon} alt=\"\" className=\"size-full object-cover\" />\n      ) : (\n        name.charAt(0)\n      )}\n    </span>\n  )\n}\n\ntype CitationProps = Omit<React.ComponentProps<\"a\">, \"href\" | \"children\"> & {\n  /** The source, or several sources backing one claim. With several, the card gets a pager. */\n  sources: CitationSource | CitationSource[]\n  /** Number shown in the marker. Ignored by the \"pill\" style. */\n  index?: number\n  /** \"number\" is a small superscript-style chip like [1]. \"pill\" shows the site name with a +N count. */\n  variant?: \"number\" | \"pill\"\n}\n\nfunction Citation({ sources, index = 1, variant = \"number\", className, ...props }: CitationProps) {\n  const msg = useMessages()\n  const list = Array.isArray(sources) ? sources : [sources]\n  const [page, setPage] = React.useState(0)\n  const active = list[Math.min(page, list.length - 1)]\n  if (!active) return null\n  const first = list[0]!\n  const host = first.site ?? hostOf(first.url)\n  const extra = list.length - 1\n  const many = list.length > 1\n\n  return (\n    <HoverCard openDelay={120} closeDelay={150}>\n      <HoverCardTrigger\n        data-slot=\"citation\"\n        href={first.url}\n        target=\"_blank\"\n        rel=\"noreferrer noopener\"\n        aria-label={\n          variant === \"pill\"\n            ? `Source: ${host}${extra ? ` and ${extra} more` : \"\"}`\n            : `Source ${index}: ${first.title}${extra ? ` and ${extra} more` : \"\"}`\n        }\n        className={cn(\n          \"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\",\n          variant === \"number\"\n            ? \"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\"\n            : \"h-6 gap-1 rounded-full border bg-background px-2 text-xs shadow-xs hover:bg-accent\",\n          className\n        )}\n        {...props}\n      >\n        {variant === \"number\" ? (\n          index\n        ) : (\n          <>\n            <span className=\"max-w-32 truncate\">{host}</span>\n            {extra > 0 && <span className=\"text-muted-foreground tabular-nums\">+{extra}</span>}\n          </>\n        )}\n      </HoverCardTrigger>\n      <HoverCardContent data-slot=\"citation-card\" className=\"grid w-[min(21rem,calc(100vw-1.5rem))] gap-2.5 p-3.5\">\n        {many && (\n          <div className=\"flex items-center justify-between text-xs text-muted-foreground\">\n            <span className=\"tabular-nums\" aria-live=\"polite\">\n              {msg(\"citation.sourceOf\", \"Source {n} of {total}\", { n: page + 1, total: list.length })}\n            </span>\n            <span className=\"flex items-center gap-0.5\">\n              <button\n                type=\"button\"\n                aria-label={msg(\"citation.previousSource\", \"Previous source\")}\n                disabled={page === 0}\n                onClick={() => setPage((p) => Math.max(0, p - 1))}\n                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\"\n              >\n                <ChevronLeft aria-hidden=\"true\" className=\"size-4 rtl:rotate-180\" />\n              </button>\n              <button\n                type=\"button\"\n                aria-label={msg(\"citation.nextSource\", \"Next source\")}\n                disabled={page === list.length - 1}\n                onClick={() => setPage((p) => Math.min(list.length - 1, p + 1))}\n                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\"\n              >\n                <ChevronRight aria-hidden=\"true\" className=\"size-4 rtl:rotate-180\" />\n              </button>\n            </span>\n          </div>\n        )}\n        <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n          <SourceFavicon source={active} />\n          <span className=\"truncate\">{active.site ?? hostOf(active.url)}</span>\n          {active.date && <span className=\"shrink-0 before:me-2 before:content-['·']\">{active.date}</span>}\n        </div>\n        <p className=\"line-clamp-2 text-sm leading-5 font-medium text-foreground\">{active.title}</p>\n        {active.snippet && (\n          <p className=\"line-clamp-3 text-[13px] leading-5 text-muted-foreground\">{active.snippet}</p>\n        )}\n        <a\n          href={active.url}\n          target=\"_blank\"\n          rel=\"noreferrer noopener\"\n          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\"\n        >\n          {msg(\"citation.openSource\", \"Open source\")}\n          <ExternalLink aria-hidden=\"true\" className=\"size-3\" />\n        </a>\n      </HoverCardContent>\n    </HoverCard>\n  )\n}\n\nexport { Citation, SourceFavicon, hostOf, type CitationProps, type CitationSource, type SourceFaviconProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/citation.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "citation",
      "source",
      "reference",
      "hover"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "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.",
      "whenToUse": [
        "Answers grounded in web or document sources",
        "Footnotes that should not pull the reader away from the text"
      ],
      "whenNotToUse": [
        "A full list of references; use sources-list",
        "Tooltips with no link; use tooltip"
      ],
      "composesWith": [
        "sources-list",
        "ai-message",
        "hover-card"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Focuses the marker and opens its card"
        },
        {
          "keys": "Enter",
          "action": "Opens the source in a new tab"
        },
        {
          "keys": "Screen readers",
          "action": "Named 'Source 1: Title', with 'and 2 more' for grouped sources"
        }
      ],
      "customization": [
        "variant",
        "index",
        "sources as one or many",
        "favicon url (monogram by default, nothing is fetched)"
      ]
    },
    "examples": [
      "citation-demo",
      "citation-pill"
    ],
    "url": "https://ui.ballmac.com/components/citation"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}