Skip to main content

ns-ui

Citation Inline Card

An inline citation pill (hostname + source count, Geist Mono) that opens one bordered card with a title, excerpt and a 1/N stepper, instead of a popover per source.

Usage

registry/core/citation-inline-card/demo.tsx
"use client";

import { MarginCite, type CiteSource } from "./component";

// Three separate citations at realistic size: a lone source, a pair, and a
// trio behind one claim — the trio is the case the component exists for. The
// prose block is deliberately wrapped in an overflow-hidden card (a common
// real-world ancestor: a bordered content panel) to demonstrate the card
// escapes that clip instead of being cut off, same hazard tooltips hit.

const LATENCY_SOURCES: CiteSource[] = [
  {
    id: "cdn-report",
    title: "State of the Edge, 2026",
    excerpt:
      "Median time-to-first-byte across the sampled edge network dropped 38% year over year, driven almost entirely by regional cache warm-up changes shipped in Q1.",
    url: "https://cdn-report.example.com/state-of-the-edge-2026",
  },
];

const PARSER_SOURCES: CiteSource[] = [
  {
    id: "parser-bench",
    title: "Streaming parsers vs. buffered: a benchmark",
    excerpt:
      "Across payload sizes from 1KB to 50MB, the streaming implementation held a flat memory profile while the buffered baseline scaled linearly with input size.",
    url: "https://bench.example.dev/streaming-vs-buffered",
  },
  {
    id: "spec-note",
    title: "Editor's note on incremental parsing",
    excerpt:
      "The working group's rationale for preferring incremental tokenizers centers on latency-sensitive consumers that cannot wait for a full document to arrive.",
    url: "https://spec.example.org/notes/incremental-parsing",
  },
];

const CACHE_SOURCES: CiteSource[] = [
  {
    id: "cache-postmortem",
    title: "Postmortem: the March cache stampede",
    excerpt:
      "Root cause was a synchronized TTL across nearly every key, so expiry landed within the same 400ms window under peak traffic and the origin took the full load at once.",
    url: "https://eng.example.com/postmortems/march-cache-stampede",
  },
  {
    id: "jitter-pattern",
    title: "TTL jitter as a stampede mitigation",
    excerpt:
      "Adding ±10% random jitter to every cache TTL spreads expirations across a window instead of a point, which is usually enough on its own without request coalescing.",
    url: "https://patterns.example.io/ttl-jitter",
  },
  {
    id: "coalescing-rfc",
    title: "RFC: request coalescing at the cache layer",
    excerpt:
      "Concurrent misses for the same key should collapse into a single origin request, with the remaining waiters served from the result once it lands rather than each dialing the origin.",
    url: "https://rfcs.example.net/request-coalescing",
  },
];

export default function MarginCiteDemo() {
  return (
    <div className="flex min-h-screen items-center justify-center px-6 py-16">
      <div className="w-full max-w-xl overflow-hidden rounded-md border border-border bg-background p-6">
        <p className="mb-1 font-mono text-[11px] uppercase tracking-[0.16em] text-ns-muted">
          Engineering notes / caching
        </p>
        <h2 className="mb-4 text-lg font-semibold tracking-tight text-foreground">
          Why we jitter cache TTLs
        </h2>
        <div className="flex flex-col gap-4 text-sm leading-relaxed text-foreground">
          <p>
            A synchronized-TTL stampede in March made it clear that uniform expiry is a liability
            at scale, and jittered TTLs combined with request coalescing at the cache layer are
            now the standard mitigation
            <MarginCite sources={CACHE_SOURCES} />. Neither change is exotic on its own; together
            they moved p99 origin load from a recurring incident to a non-issue.
          </p>
          <p>
            The rest of the win came from elsewhere. Edge response times improved considerably
            after last quarter&rsquo;s cache changes
            <MarginCite sources={LATENCY_SOURCES} />, and parsing overhead on the misses that
            remained dropped once a streaming approach replaced the buffered one we started with
            <MarginCite sources={PARSER_SOURCES} />.
          </p>
        </div>
      </div>
    </div>
  );
}

Component

registry/core/citation-inline-card/component.tsx
"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { createPortal } from "react-dom";

// MarginCite — an inline citation primitive. The trigger is one monochrome
// superscript pill (hostname of the primary source, plus a small source
// count when there's more than one) sitting right in the text flow. Opening
// it reveals ONE bordered card — title, excerpt, a "1 / N" stepper — instead
// of a popover-per-source; three sources behind a claim is one card the
// reader pages through, not three stacked callouts.
//
// The card is rendered through a portal into document.body and positioned
// with `position: fixed`, coordinates read from the trigger's own
// getBoundingClientRect on open, on scroll and on resize. That's the fix for
// the same clipping hazard tooltips and menus hit: a card left as a plain
// `absolute` child of the trigger gets silently cut off by the first
// ancestor with overflow-hidden (a common wrapper around body copy). It also
// flips above the trigger when there isn't room below, and clamps
// horizontally so it never runs off the viewport edge.
//
// Fully keyboard-operable: the trigger is a real button (Enter/Space toggle
// it natively), focus moves into the open card so Tab reaches Previous,
// Next and the source link in order, Left/Right arrow keys step the reader
// through sources without leaving the keyboard, and Escape closes the card
// and returns focus to the trigger. A visually-hidden status region
// announces "Source 2 of 3" on every step so paging is legible to assistive
// tech, not just sighted users watching the "1 / N" readout update.
//
// Zero dependencies beyond react-dom's createPortal. No canvas — the card is
// plain DOM, and every color is a token (--background --foreground --ns-muted
// --border --ns-accent) so both themes render correctly. Under
// prefers-reduced-motion the entrance animation is dropped via a CSS media
// query; the card still opens instantly and is fully operable.

export type CiteSource = {
  id: string;
  title: string;
  excerpt: string;
  /** full URL; hostname is derived from it for both the pill and the card footer */
  url: string;
};

export interface MarginCiteProps {
  /** the citable sources, referenced by inline markers in the surrounding text */
  sources: CiteSource[];
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const GAP = 8; // px between trigger and card
const CARD_WIDTH = 320;
const VIEWPORT_MARGIN = 12; // px clamp from viewport edges

function hostnameOf(url: string): string {
  try {
    return new URL(url).hostname.replace(/^www\./, "");
  } catch {
    // not a parseable absolute URL — fall back to a best-effort strip
    return url.replace(/^https?:\/\//, "").split(/[/?#]/)[0] || url;
  }
}

function ChevronIcon({ dir }: { dir: "left" | "right" }) {
  return (
    <svg
      viewBox="0 0 16 16"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
    >
      <path d={dir === "left" ? "m10 3-5 5 5 5" : "m6 3 5 5-5 5"} />
    </svg>
  );
}

function CloseIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      className="h-3.5 w-3.5"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      aria-hidden
    >
      <path d="M4 4l8 8M12 4l-8 8" />
    </svg>
  );
}

function LinkOutIcon() {
  return (
    <svg
      viewBox="0 0 16 16"
      className="h-3 w-3"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
    >
      <path d="M6.5 9.5 13 3M8.5 3H13v4.5M13 9.5V13a1 1 0 0 1-1 1H3.5a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1H7" />
    </svg>
  );
}

const iconBtnClass =
  "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-ns-muted transition-colors duration-150 ease-out " +
  "hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ns-accent " +
  "disabled:pointer-events-none disabled:opacity-30";

export function MarginCite({ sources, className = "" }: MarginCiteProps) {
  const n = sources.length;
  const [open, setOpen] = useState(false);
  const [index, setIndex] = useState(0);
  const [pos, setPos] = useState<{ top: number; left: number } | null>(null);

  const triggerRef = useRef<HTMLButtonElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const uid = useId();
  const titleId = `${uid}-title`;
  const panelId = `${uid}-panel`;

  // position the portaled card off the trigger's live rect — recomputed on
  // open, on every step (the excerpt's length changes the card's height,
  // which can change whether it fits below) and on scroll/resize
  useEffect(() => {
    if (!open) return;
    const place = () => {
      const trigger = triggerRef.current;
      if (!trigger) return;
      const tr = trigger.getBoundingClientRect();
      const cardW = panelRef.current?.offsetWidth || CARD_WIDTH;
      const cardH = panelRef.current?.offsetHeight || 140;

      let left = tr.left;
      const maxLeft = window.innerWidth - cardW - VIEWPORT_MARGIN;
      if (left > maxLeft) left = maxLeft;
      if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;

      const spaceBelow = window.innerHeight - tr.bottom;
      const flip = spaceBelow < cardH + GAP && tr.top > cardH + GAP;
      const top = flip ? tr.top - cardH - GAP : tr.bottom + GAP;

      setPos({ top, left });
    };
    place();
    window.addEventListener("resize", place);
    document.addEventListener("scroll", place, true);
    return () => {
      window.removeEventListener("resize", place);
      document.removeEventListener("scroll", place, true);
    };
  }, [open, index]);

  // outside pointerdown closes
  useEffect(() => {
    if (!open) return;
    const onPointerDown = (e: PointerEvent) => {
      const t = e.target as Node;
      if (panelRef.current?.contains(t) || triggerRef.current?.contains(t)) return;
      setOpen(false);
    };
    document.addEventListener("pointerdown", onPointerDown, true);
    return () => document.removeEventListener("pointerdown", onPointerDown, true);
  }, [open]);

  // Escape closes from anywhere, not only while focus happens to sit inside the
  // card. A non-modal popover that survives Escape because focus drifted (or was
  // dropped entirely) is a real trap — it keeps floating over the prose with no
  // keyboard way out.
  useEffect(() => {
    if (!open) return;
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key !== "Escape") return;
      e.preventDefault();
      setOpen(false);
      triggerRef.current?.focus();
    };
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, [open]);

  // Focus lands inside the card on open, so Tab immediately reaches its
  // controls in order rather than continuing through the surrounding text.
  // This has to wait for `pos` — the panel isn't mounted at all until the
  // placement effect has measured the trigger, so keying this on `open` alone
  // ran while panelRef was still null and focus silently stayed on the pill.
  const placed = pos !== null;
  useEffect(() => {
    if (!open || !placed) return;
    const id = requestAnimationFrame(() => panelRef.current?.focus({ preventScroll: true }));
    return () => cancelAnimationFrame(id);
  }, [open, placed]);

  if (n === 0) return null;

  const clampedIndex = Math.min(index, n - 1);
  const current = sources[clampedIndex];
  const primaryHost = hostnameOf(sources[0].url);

  const openCard = () => {
    setIndex(0);
    setOpen(true);
  };
  const closeCard = (focusTrigger: boolean) => {
    setOpen(false);
    if (focusTrigger) triggerRef.current?.focus();
  };
  const step = (delta: number) => {
    setIndex((i) => Math.min(n - 1, Math.max(0, i + delta)));
  };

  const onPanelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (e.key === "Escape") {
      e.preventDefault();
      closeCard(true);
    } else if (e.key === "ArrowLeft" && clampedIndex > 0) {
      e.preventDefault();
      step(-1);
    } else if (e.key === "ArrowRight" && clampedIndex < n - 1) {
      e.preventDefault();
      step(1);
    }
  };

  return (
    <span className={`ns-citation-inline-card relative inline whitespace-nowrap ${className}`}>
      <button
        ref={triggerRef}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-controls={open ? panelId : undefined}
        onClick={() => (open ? closeCard(false) : openCard())}
        onKeyDown={(e) => {
          if (open && e.key === "Escape") {
            e.preventDefault();
            closeCard(true);
          }
        }}
        className={[
          "relative -top-[0.5em] ml-0.5 inline-flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 align-baseline",
          "font-mono text-[0.65rem] leading-none",
          "transition-colors duration-150 ease-out",
          "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent",
          open
            ? "border-foreground/30 bg-surface text-foreground"
            : "border-border bg-surface text-ns-muted hover:border-foreground/30 hover:text-foreground",
        ].join(" ")}
      >
        <span>{primaryHost}</span>
        {n > 1 && <span className="opacity-70">·{n}</span>}
        <span className="sr-only">
          , {n} source{n === 1 ? "" : "s"} cited
        </span>
      </button>

      <span role="status" aria-live="polite" className="sr-only">
        {open ? `Source ${clampedIndex + 1} of ${n}` : ""}
      </span>

      {open &&
        pos &&
        typeof document !== "undefined" &&
        createPortal(
          <div
            ref={panelRef}
            id={panelId}
            role="dialog"
            aria-labelledby={titleId}
            tabIndex={-1}
            onKeyDown={onPanelKeyDown}
            // Non-modal popover: once focus leaves the card entirely it closes,
            // rather than being left floating over the prose while the reader is
            // somewhere else on the page. Focus moving *within* the card (the
            // stepper buttons, the source link) is not a leave.
            onBlur={(e) => {
              const next = e.relatedTarget as Node | null;
              if (next && (panelRef.current?.contains(next) || triggerRef.current?.contains(next))) {
                return;
              }
              setOpen(false);
            }}
            style={{ position: "fixed", top: pos.top, left: pos.left, width: CARD_WIDTH }}
            className="ns-citation-inline-card-panel z-50 max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-md border border-border bg-surface text-foreground shadow-lg outline-none"
          >
            <div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
              <span aria-hidden className="font-mono text-[11px] tabular-nums tracking-wide text-ns-muted">
                {clampedIndex + 1}
                {/* --border is tuned to disappear against a surface, which is
                    right for a hairline and wrong for a glyph: as `text-border`
                    this slash vanished and the readout rendered as "1  3". */}
                <span className="mx-0.5 text-ns-muted/50">/</span>
                {n}
              </span>
              <div className="flex items-center gap-0.5">
                {n > 1 && (
                  <>
                    <button
                      type="button"
                      aria-label="Previous source"
                      disabled={clampedIndex === 0}
                      onClick={() => step(-1)}
                      className={iconBtnClass}
                    >
                      <ChevronIcon dir="left" />
                    </button>
                    <button
                      type="button"
                      aria-label="Next source"
                      disabled={clampedIndex === n - 1}
                      onClick={() => step(1)}
                      className={iconBtnClass}
                    >
                      <ChevronIcon dir="right" />
                    </button>
                  </>
                )}
                <button
                  type="button"
                  aria-label="Close citation"
                  onClick={() => closeCard(true)}
                  className={iconBtnClass}
                >
                  <CloseIcon />
                </button>
              </div>
            </div>

            <div className="flex flex-col gap-1.5 px-3.5 py-3">
              <p id={titleId} className="text-sm font-semibold leading-snug text-foreground">
                {current.title}
              </p>
              <p className="line-clamp-3 text-sm leading-relaxed text-ns-muted">{current.excerpt}</p>
              <a
                href={current.url}
                target="_blank"
                rel="noopener noreferrer"
                className="mt-0.5 inline-flex w-fit items-center gap-1 font-mono text-xs text-ns-muted transition-colors duration-150 ease-out hover:text-ns-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
              >
                {hostnameOf(current.url)}
                <LinkOutIcon />
              </a>
            </div>
            <style>{`
.ns-citation-inline-card-panel{animation:ns-mc-in 160ms cubic-bezier(0.16,1,0.3,1) both}
@keyframes ns-mc-in{from{opacity:0;transform:translateY(-4px) scale(.98)}to{opacity:1;transform:none}}
@media (prefers-reduced-motion: reduce){.ns-citation-inline-card-panel{animation:none}}
`}</style>
          </div>,
          document.body
        )}
    </span>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/citation-inline-card.tsx, so point the import there.

Use when a claim needs attribution at the point it is made and one claim may carry several sources, so a superscript hostname pill opens a single card with a 1/N stepper instead of stacking one popover per source; pick citation-grounding-hatch instead when the question is how much of the whole answer is supported, since pills can only mark where a source was attached and say nothing about the spans with none.

Install

npx shadcn add https://design.helpmarq.com/r/citation-inline-card.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

An inline citation primitive for attributing a claim to one or more sources, taking a single `sources` array prop (`{ id, title, excerpt, url }[]`). The trigger is one monochrome superscript pill sitting in the text flow — the hostname of the first source in Geist Mono, raised and shrunk like a footnote marker, with a small `·N` suffix appended when more than one source backs the claim. Activating it (click, or Enter/Space since it's a real button) opens a single bordered card rather than one popover per source: a header row shows a `1 / N` mono readout plus Previous/Next chevron buttons (only rendered when N>1) and a close button, and the body shows the current source's title, a 3-line-clamped excerpt, and a hostname link that opens the source in a new tab. Paging through sources updates a visually-hidden `role=status aria-live=polite` region with 'Source 2 of 3' on every step, so the position change is announced to assistive tech exactly like the visible '2 / 3' readout is to sighted users. The card is rendered through a portal into `document.body` and positioned with `position: fixed`, coordinates read from the trigger's `getBoundingClientRect()` on open, on every step (the excerpt's length can change the card's height) and on scroll/resize — not laid out as a plain `absolute` child of the trigger, which is what lets an ordinary ancestor with `overflow-hidden` (a bordered content panel, a card, a table cell) silently clip it. It also flips above the trigger when there isn't room below and clamps horizontally to stay inside the viewport. Fully keyboard-operable: opening moves focus into the card so Tab reaches Previous, Next and the source link in visible order; Left/Right arrow keys step through sources without leaving the keyboard; Escape closes the card and returns focus to the trigger from anywhere on the page, not only while focus happens to sit inside the card — a non-modal popover that survives Escape because focus drifted is a real keyboard trap. An outside pointerdown closes it, and so does focus leaving the card entirely (moving *within* the card, between the stepper buttons and the source link, is not a leave), so the card never sits floating over the prose while the reader is somewhere else. The card is `role="dialog"` with `aria-labelledby` pointing at the title, so it always carries an accessible name. Hostnames are derived from each source's `url` via the `URL` constructor with a `www.` strip, falling back to a best-effort string strip for a non-absolute URL rather than throwing. Zero dependencies beyond react-dom's `createPortal`; no canvas; every color is a token (`--background --foreground --ns-muted --border --ns-accent`) so both themes render correctly, and `--ns-accent` only shows up on hover/focus states, never as decoration. Under `prefers-reduced-motion` the card's entrance animation is dropped via a CSS media query — it still opens instantly and is fully operable, nothing is lost.

Props

PropTypeDefaultDescription
sourcesCiteSource[]—the citable sources, referenced by inline markers in the surrounding text
className?string—extra classes merged onto the rendered root element