{"slug":"inline-citation","category":"text","job":"trust","added":"2026-09-12T06:58Z","title":"Inline Citation","description":"A hoverable, clickable footnote-style source marker inline within text.","url":"https://ai-patterns.app/patterns/text/inline-citation","markdown":"https://ai-patterns.app/patterns/text/inline-citation.md","json":"https://ai-patterns.app/api/patterns/text/inline-citation","seenIn":[{"team":"Perplexity","source":"https://www.perplexity.ai/help-center/en/articles/10352895-how-does-perplexity-work"},{"team":"chatgpt","source":"https://help.openai.com/en/articles/9237897-chatgpt-search"},{"team":"Intercom","source":"https://fin.ai/help/en/articles/13975900-copilot-explained"},{"team":"Notion","source":"https://www.notion.com/help/enterprise-search"}],"spec":"# Inline Citation\n\n## Summary\nA small, numbered footnote-style marker set right after a specific claim in the text, that reveals its source — title, domain, a short snippet, and a link out — on hover or click, without breaking the reader's flow. It's the claim-level counterpart to a general \"here's what I searched\" indicator.\n\n## When to use\n- A specific sentence or clause in the answer is backed by a specific source, and the user may want to verify it without leaving the page or scrolling to a source list.\n- Any streamed or static answer that cites multiple distinct sources for different claims, where a single end-of-answer source list would lose the claim-to-source mapping.\n\n## When not to use\n- For a blanket \"this response used web search\" signal with no specific claim attached — that belongs in the answer's own inline source chip (see Related), not a numbered footnote.\n- On nearly every sentence. Citing everything turns the text into a wall of superscripts and trains the user to stop noticing them — reserve this for claims that actually need backing.\n- As the only way to see all sources at once — pair it with a full source list when the user wants the complete picture, this marker is for in-context verification of one claim.\n\n## Anatomy\n- Marker: a small raised numeral (or a compact icon + numeral), inline immediately after the clause it supports.\n- Popover: source title, domain/favicon, a one-line snippet, and a link to open the source.\n- Multiple citations on one clause render as adjacent numerals, each independently triggerable — never merged into a single marker.\n\n## Behavior\n- Hovering or focusing the marker opens the popover; it closes on mouse-leave/blur, on Escape, or on an outside click.\n- On touch devices, where hover doesn't apply, tapping the marker opens the popover; tapping again or tapping outside closes it.\n- The popover repositions to stay on-screen near a viewport edge (flips above/below or left/right as needed) rather than clipping.\n- Numerals count up in the order sources first appear in the text, not alphabetically or by source importance.\n\n## Content guidelines\n- The snippet is a short excerpt that supports the specific claim, not the whole source page.\n- Marker numerals are literal reference numbers, not a rating or confidence score — don't overload their meaning.\n\n## Accessibility\n- The marker is a real focusable element (`<button>` or `<a>`), never a styled, non-interactive `<span>`.\n- The popover content is associated via `aria-describedby` (or an equivalent live association), so assistive tech can reach it from the marker.\n- Never rely on hover alone — keyboard focus and touch tap must open the same popover.\n\n## Related patterns\n- **Streaming Text** — Use its inline source chip instead when a word-level this-came-from-search signal is enough.\n- **Sources Stack** — Use it as well for an answer-level summary of every source used.\n- **Source Trust Card** — Use it as well to expand a citation into a full source preview.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface CitationSource {\n  title: string;\n  domain: string;\n  snippet?: string;\n  url: string;\n}\n\nexport interface InlineCitationProps {\n  index: number;\n  source: CitationSource;\n  className?: string;\n}\n\nconst POPOVER_WIDTH = 256;\nconst MARGIN = 8;\n\ninterface Position {\n  top: number;\n  left: number;\n  placement: \"top\" | \"bottom\";\n}\n\nexport function InlineCitation({ index, source, className }: InlineCitationProps) {\n  const [open, setOpen] = React.useState(false);\n  const [position, setPosition] = React.useState<Position | null>(null);\n  const mounted = useMounted();\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const popoverRef = React.useRef<HTMLDivElement>(null);\n  const popoverId = React.useId();\n\n  const reposition = React.useCallback(() => {\n    const rect = triggerRef.current?.getBoundingClientRect();\n    if (!rect) return;\n    const placement: Position[\"placement\"] = rect.top > 180 ? \"top\" : \"bottom\";\n    const left = Math.min(\n      Math.max(rect.left + rect.width / 2, POPOVER_WIDTH / 2 + MARGIN),\n      window.innerWidth - POPOVER_WIDTH / 2 - MARGIN\n    );\n    setPosition({ top: placement === \"top\" ? rect.top : rect.bottom, left, placement });\n  }, []);\n\n  function show() {\n    reposition();\n    setOpen(true);\n  }\n\n  React.useEffect(() => {\n    if (!open) return;\n\n    function handlePointerDown(e: MouseEvent) {\n      const target = e.target as Node;\n      if (triggerRef.current?.contains(target) || popoverRef.current?.contains(target)) return;\n      setOpen(false);\n    }\n    function handleKey(e: KeyboardEvent) {\n      if (e.key === \"Escape\") setOpen(false);\n    }\n\n    document.addEventListener(\"mousedown\", handlePointerDown);\n    document.addEventListener(\"keydown\", handleKey);\n    window.addEventListener(\"scroll\", reposition, true);\n    window.addEventListener(\"resize\", reposition);\n    return () => {\n      document.removeEventListener(\"mousedown\", handlePointerDown);\n      document.removeEventListener(\"keydown\", handleKey);\n      window.removeEventListener(\"scroll\", reposition, true);\n      window.removeEventListener(\"resize\", reposition);\n    };\n  }, [open, reposition]);\n\n  return (\n    <>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-describedby={open ? popoverId : undefined}\n        aria-expanded={open}\n        onMouseEnter={show}\n        onMouseLeave={() => setOpen(false)}\n        onFocus={show}\n        onBlur={() => setOpen(false)}\n        onClick={() => (open ? setOpen(false) : show())}\n        className={cn(\n          \"mx-0.5 inline-flex size-4 -translate-y-1.5 items-center justify-center rounded-full bg-muted align-super text-[10px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground\",\n          className\n        )}\n      >\n        {index}\n      </button>\n\n      {mounted &&\n        createPortal(\n          <AnimatePresence>\n            {open && position && (\n              // Positioning lives on this plain element, not the motion.div below — Motion owns the\n              // `transform` style for its own x/y animation, so a hand-written translate() here would\n              // get silently overwritten by it.\n              <div\n                ref={popoverRef}\n                style={{\n                  position: \"fixed\",\n                  top: position.top,\n                  left: position.left,\n                  width: POPOVER_WIDTH,\n                  transform:\n                    position.placement === \"top\"\n                      ? `translate(-50%, calc(-100% - ${MARGIN}px))`\n                      : `translate(-50%, ${MARGIN}px)`,\n                }}\n                className=\"z-50\"\n              >\n                <motion.div\n                  id={popoverId}\n                  role=\"tooltip\"\n                  initial={{ opacity: 0, y: position.placement === \"top\" ? 4 : -4 }}\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={{ opacity: 0, y: position.placement === \"top\" ? 4 : -4 }}\n                  transition={{ duration: 0.12 }}\n                  className=\"rounded-xl border bg-popover p-3 text-left shadow-md\"\n                >\n                  <a\n                    href={source.url}\n                    target=\"_blank\"\n                    rel=\"noreferrer\"\n                    className=\"block text-sm font-medium text-foreground hover:underline\"\n                  >\n                    {source.title}\n                  </a>\n                  <p className=\"mt-0.5 text-xs text-muted-foreground\">{source.domain}</p>\n                  {source.snippet && <p className=\"mt-1.5 text-xs text-foreground/80\">{source.snippet}</p>}\n                </motion.div>\n              </div>\n            )}\n          </AnimatePresence>,\n          document.body\n        )}\n    </>\n  );\n}\n\n/** True only once the client has rendered — lets a portal target `document.body` without an SSR mismatch. */\nfunction useMounted() {\n  return React.useSyncExternalStore(\n    () => () => {},\n    () => true,\n    () => false\n  );\n}\n","dependencies":["motion"],"localImports":["@/lib/utils"]}