{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "citations",
  "title": "Citations",
  "description": "Shows where an AI answer came from — a small chip after each claim, named for its source, that previews it, and an \"N sources\" list under the answer.",
  "registryDependencies": [
    "hover-card",
    "https://www.quilldesignsystem.com/r/icon.json"
  ],
  "files": [
    {
      "path": "registry/lib/citations.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'\nimport { Icon } from '@/components/ui/icon'\nimport { cn } from '@/lib/utils'\n\nexport type Source = {\n  title: string\n  /** Short chip text, e.g. \"Q3 deck\". Without it the chip shows the title, truncated. */\n  label?: string\n  /** Where in the source, or its address: \"Page 4\", \"example.com/changelog\". */\n  detail?: string\n  /** A line from the source, shown in the preview. */\n  snippet?: string\n  /** Opens in a new tab when given; without it the chip is a button that opens its preview when pressed. */\n  href?: string\n  kind?: 'file' | 'web'\n}\n\n// The focus ring is the stock Button's, so these plain buttons and links match the controls around them.\n// Truncation keeps a long title from turning one chip into a line-wide bar; the full title stays in the accessible name.\n// mx-1.5 leaves 3px clear outside the 3px focus ring on both sides, so the chip never touches the words around it however it is placed.\nconst CHIP = 'mx-1.5 inline-block h-5 max-w-chip truncate rounded-md bg-muted px-2 text-center align-middle text-2xs font-semibold leading-5 text-ink-soft no-underline hover:bg-foreground hover:text-background outline-hidden focus-visible:ring-3 focus-visible:ring-ring/50'\nconst kindIcon = (s: Source) => (s.kind === 'web' ? 'language' : 'description')\n\nfunction where(s: Source) {\n  if (s.detail) return s.detail\n  if (!s.href) return s.kind === 'web' ? 'Web page' : 'File'\n  try { return new URL(s.href).hostname } catch { return s.href }\n}\n\n/** Shows where an AI answer came from — a small chip after each claim, named for its source, that previews it, and an \"N sources\" list under the answer. */\nexport function Citation({ source, className }: { source: Source; className?: string }) {\n  // WCAG 2.5.3: the accessible name must start with the visible chip text.\n  const name = source.label ? `${source.label}, source: ${source.title}` : `Source: ${source.title}`\n  // A chip with a link already has a press (follow it); one without gets the preview as its press.\n  const pressable = !source.href\n  // Always controlled, so a chip whose href comes or goes while mounted never flips the card between controlled and uncontrolled.\n  const [open, setOpen] = React.useState(false)\n  // A press pins the card open. Hover or focus may already have opened it, and a press must never be the thing\n  // that closes it, so the first press pins and only the next press (or Escape, an outside press, focus leaving) lets go.\n  const [pinned, setPinned] = React.useState(false)\n  // A chip that gains or loses its link must not carry an open, pinned card across; reset during render, the React-sanctioned way to react to a prop change.\n  const [wasPressable, setWasPressable] = React.useState(pressable)\n  if (wasPressable !== pressable) {\n    setWasPressable(pressable)\n    setPinned(false)\n    setOpen(false)\n  }\n  const chipRef = React.useRef<HTMLAnchorElement>(null)\n  const release = () => { setPinned(false); setOpen(false) }\n  return (\n    <HoverCard\n      open={open}\n      onOpenChange={(nextOpen, details) => {\n        // While pinned the pointer leaving must not close the card. Focus leaving does close it, and the primitive\n        // ignores focus moving into the card itself, so pressing inside the card does not count as leaving.\n        if (!nextOpen && pinned && details.reason === 'trigger-hover') return\n        if (!nextOpen) setPinned(false)\n        setOpen(nextOpen)\n      }}\n    >\n      <HoverCardTrigger\n        render={source.href\n          ? <a href={source.href} target=\"_blank\" rel=\"noreferrer\" aria-label={name} />\n          : <button type=\"button\" aria-label={name} aria-expanded={open} />}\n        ref={chipRef}\n        data-slot=\"citation\"\n        className={cn(CHIP, className)}\n        {...(pressable && {\n          onClick: () => (pinned ? release() : (setPinned(true), setOpen(true))),\n        })}\n      >\n        {source.label ?? source.title}\n      </HoverCardTrigger>\n      {/* Focus can rest inside the card after a press in it; leaving it for anywhere but the chip or the card lets go. */}\n      <HoverCardContent\n        className=\"grid w-panel gap-1\"\n        onBlur={(event) => {\n          const next = event.relatedTarget as Node | null\n          if (!pinned || next === chipRef.current || event.currentTarget.contains(next)) return\n          release()\n        }}\n      >\n        <span className=\"flex items-center gap-1.5 text-xs text-muted-foreground\"><Icon name={kindIcon(source)} className=\"size-icon-xs\" />{where(source)}</span>\n        <span className=\"font-semibold\">{source.title}</span>\n        {source.snippet ? <span className=\"text-muted-foreground\">{source.snippet}</span> : null}\n      </HoverCardContent>\n    </HoverCard>\n  )\n}\n\n/** \"N sources\" under an AI answer, opening the list the chips point to. */\nexport function Sources({ sources, defaultOpen = false, className }: { sources: Source[]; defaultOpen?: boolean; className?: string }) {\n  const [open, setOpen] = React.useState(defaultOpen)\n  const listId = React.useId()\n  if (!sources.length) return null\n  return (\n    <div data-slot=\"sources\" className={cn('grid justify-items-start gap-2', className)}>\n      <button type=\"button\" aria-expanded={open} aria-controls={listId} onClick={() => setOpen((wasOpen) => !wasOpen)}\n        className=\"inline-flex h-7 items-center gap-2 rounded-full border border-border bg-background pr-2.5 pl-1.5 text-xs font-semibold text-ink-soft hover:bg-muted outline-hidden focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50\">\n        <span aria-hidden className=\"flex\">\n          {sources.slice(0, 3).map((source, index) => (\n            <span key={index} className={cn('grid size-4.5 place-items-center rounded-full border-2 border-background bg-muted', index > 0 && '-ml-1.5')}>\n              <Icon name={kindIcon(source)} className=\"size-icon-2xs\" />\n            </span>\n          ))}\n        </span>\n        {sources.length === 1 ? '1 source' : `${sources.length} sources`}\n      </button>\n      {/* A list, not a numbered one: chips are named, so order carries no meaning. */}\n      <ul id={listId} hidden={!open} className=\"grid w-full gap-0.5 rounded-lg border border-border bg-background p-1\">\n        {sources.map((source, index) => {\n          const body = (\n            <>\n              <span aria-hidden className=\"grid size-6 place-items-center rounded-md bg-muted text-ink-soft\"><Icon name={kindIcon(source)} className=\"size-icon-sm\" /></span>\n              <span className=\"grid min-w-0\">\n                <span className=\"truncate font-semibold\">{source.title}</span>\n                {source.detail ? <span className=\"truncate text-xs text-muted-foreground\">{source.detail}</span> : null}\n              </span>\n            </>\n          )\n          const row = 'grid grid-cols-[1.375rem_minmax(0,1fr)] items-start gap-2.5 rounded-md p-2 text-sm'\n          return (\n            <li key={index}>\n              {source.href\n                ? <a href={source.href} target=\"_blank\" rel=\"noreferrer\" className={cn(row, 'text-foreground no-underline hover:bg-card outline-hidden focus-visible:ring-3 focus-visible:ring-ring/50')}>{body}</a>\n                : <div className={row}>{body}</div>}\n            </li>\n          )\n        })}\n      </ul>\n    </div>\n  )\n}\n",
      "type": "registry:ui",
      "target": "components/ui/citations.tsx"
    }
  ],
  "docs": "Shows where an AI answer came from — a small chip after each claim, named for its source, that previews it, and an \"N sources\" list under the answer. Do: Put the chip right after the sentence it backs, named for its source. Don't: Collect sources only in a footer — people cannot tell which source backs which claim. Do: Leave the chips and list in neutral ink. Don't: Colour citations with the AI gradient or a status colour — they are references, not AI and not status. Use hover-card when you need a preview on an ordinary link that is not an AI source. Use badge when you are labelling a category, not pointing to where a claim came from.",
  "type": "registry:ui"
}