AI Patterns

text

Inline Citation

A hoverable, clickable footnote-style source marker inline within text.

Tailwind v4 moved its configuration into CSS itself, dropping the old tailwind.config.js file entirely. Motion, formerly Framer Motion, now ships a smaller core bundle aimed specifically at this kind of micro-interaction.

"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "motion/react";

import { cn } from "@/lib/utils";

export interface CitationSource {
  title: string;
  domain: string;
  snippet?: string;
  url: string;
}

export interface InlineCitationProps {
  index: number;
  source: CitationSource;
  className?: string;
}

const POPOVER_WIDTH = 256;
const MARGIN = 8;

interface Position {
  top: number;
  left: number;
  placement: "top" | "bottom";
}

export function InlineCitation({ index, source, className }: InlineCitationProps) {
  const [open, setOpen] = React.useState(false);
  const [position, setPosition] = React.useState<Position | null>(null);
  const mounted = useMounted();
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const popoverRef = React.useRef<HTMLDivElement>(null);
  const popoverId = React.useId();

  const reposition = React.useCallback(() => {
    const rect = triggerRef.current?.getBoundingClientRect();
    if (!rect) return;
    const placement: Position["placement"] = rect.top > 180 ? "top" : "bottom";
    const left = Math.min(
      Math.max(rect.left + rect.width / 2, POPOVER_WIDTH / 2 + MARGIN),
      window.innerWidth - POPOVER_WIDTH / 2 - MARGIN
    );
    setPosition({ top: placement === "top" ? rect.top : rect.bottom, left, placement });
  }, []);

  function show() {
    reposition();
    setOpen(true);
  }

  React.useEffect(() => {
    if (!open) return;

    function handlePointerDown(e: MouseEvent) {
      const target = e.target as Node;
      if (triggerRef.current?.contains(target) || popoverRef.current?.contains(target)) return;
      setOpen(false);
    }
    function handleKey(e: KeyboardEvent) {
      if (e.key === "Escape") setOpen(false);
    }

    document.addEventListener("mousedown", handlePointerDown);
    document.addEventListener("keydown", handleKey);
    window.addEventListener("scroll", reposition, true);
    window.addEventListener("resize", reposition);
    return () => {
      document.removeEventListener("mousedown", handlePointerDown);
      document.removeEventListener("keydown", handleKey);
      window.removeEventListener("scroll", reposition, true);
      window.removeEventListener("resize", reposition);
    };
  }, [open, reposition]);

  return (
    <>
      <button
        ref={triggerRef}
        type="button"
        aria-describedby={open ? popoverId : undefined}
        aria-expanded={open}
        onMouseEnter={show}
        onMouseLeave={() => setOpen(false)}
        onFocus={show}
        onBlur={() => setOpen(false)}
        onClick={() => (open ? setOpen(false) : show())}
        className={cn(
          "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",
          className
        )}
      >
        {index}
      </button>

      {mounted &&
        createPortal(
          <AnimatePresence>
            {open && position && (
              // Positioning lives on this plain element, not the motion.div below — Motion owns the
              // `transform` style for its own x/y animation, so a hand-written translate() here would
              // get silently overwritten by it.
              <div
                ref={popoverRef}
                style={{
                  position: "fixed",
                  top: position.top,
                  left: position.left,
                  width: POPOVER_WIDTH,
                  transform:
                    position.placement === "top"
                      ? `translate(-50%, calc(-100% - ${MARGIN}px))`
                      : `translate(-50%, ${MARGIN}px)`,
                }}
                className="z-50"
              >
                <motion.div
                  id={popoverId}
                  role="tooltip"
                  initial={{ opacity: 0, y: position.placement === "top" ? 4 : -4 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: position.placement === "top" ? 4 : -4 }}
                  transition={{ duration: 0.12 }}
                  className="rounded-xl border bg-popover p-3 text-left shadow-md"
                >
                  <a
                    href={source.url}
                    target="_blank"
                    rel="noreferrer"
                    className="block text-sm font-medium text-foreground hover:underline"
                  >
                    {source.title}
                  </a>
                  <p className="mt-0.5 text-xs text-muted-foreground">{source.domain}</p>
                  {source.snippet && <p className="mt-1.5 text-xs text-foreground/80">{source.snippet}</p>}
                </motion.div>
              </div>
            )}
          </AnimatePresence>,
          document.body
        )}
    </>
  );
}

/** True only once the client has rendered — lets a portal target `document.body` without an SSR mismatch. */
function useMounted() {
  return React.useSyncExternalStore(
    () => () => {},
    () => true,
    () => false
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Inline Citation

## Summary
A 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.

## When to use
- 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.
- 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.

## When not to use
- 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.
- 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.
- 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.

## Anatomy
- Marker: a small raised numeral (or a compact icon + numeral), inline immediately after the clause it supports.
- Popover: source title, domain/favicon, a one-line snippet, and a link to open the source.
- Multiple citations on one clause render as adjacent numerals, each independently triggerable — never merged into a single marker.

## Behavior
- Hovering or focusing the marker opens the popover; it closes on mouse-leave/blur, on Escape, or on an outside click.
- On touch devices, where hover doesn't apply, tapping the marker opens the popover; tapping again or tapping outside closes it.
- The popover repositions to stay on-screen near a viewport edge (flips above/below or left/right as needed) rather than clipping.
- Numerals count up in the order sources first appear in the text, not alphabetically or by source importance.

## Content guidelines
- The snippet is a short excerpt that supports the specific claim, not the whole source page.
- Marker numerals are literal reference numbers, not a rating or confidence score — don't overload their meaning.

## Accessibility
- The marker is a real focusable element (`<button>` or `<a>`), never a styled, non-interactive `<span>`.
- The popover content is associated via `aria-describedby` (or an equivalent live association), so assistive tech can reach it from the marker.
- Never rely on hover alone — keyboard focus and touch tap must open the same popover.

## Related patterns
- Streaming Text's inline source chip is the word-level "this came from a search" signal; Inline Citation is the claim-level footnote built on top of a specific source.