Citation Cards
AIInline source markers with hover cards, the way AI answers footnote their claims. The card is portaled and collision-aware — it flips above the marker near the viewport edge and shifts to stay on-screen with its arrow still pointing home. Hovering a marker or its source row syncs both ways, and it's fully keyboard-navigable.
To reveal a panel smoothly, ease grid-template-rows from 0fr to 1fr1 so it animates to its real height with no measurement. Gate the transition behind a reduced-motion check2 for accessibility, and render any floating card through a portal3 so it never gets clipped by an overflow container.
- [1]Animating height with grid-template-rowscss-tricks.com
Transitioning grid-template-rows from 0fr to 1fr lets a container ease open to its content's natural height — no fixed pixel value, no JS measurement.
- [2]prefers-reduced-motion — MDNdeveloper.mozilla.org
A media feature that detects whether the user has asked the system to minimize non-essential motion, so animations can be toned down or removed.
- [3]Rendering overlays with createPortalreact.dev
createPortal renders children into a different part of the DOM — ideal for popovers and tooltips that must escape an ancestor's overflow clipping.
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
/**
* Citations — inline source markers with hover cards, the pattern AI answers use
* to footnote their claims.
*
* <Citations sources={sources}>
* <p>
* The grid-rows technique animates height<Cite id="a" /> with no JS
* measurement<Cite id="b" />.
* </p>
* <CitationList />
* </Citations>
*
* `sources` is the data; each `<Cite id>` renders a numbered marker keyed to a
* source, and `<CitationList>` renders the footnote list. Two hard parts:
*
* 1. **Collision-aware positioning.** The hover card is measured and placed with
* `position: fixed` in a portal, so no overflow-clipping ancestor can trap
* it. It prefers to sit below the marker but flips above when the viewport
* runs out of room, and shifts horizontally to stay on-screen while its
* arrow keeps pointing at the marker. It re-solves on scroll and resize.
* 2. **Two-way hover sync.** `openId` is the single shared source of truth —
* hovering/focusing a marker *or* a list row sets it, and both the marker
* and its list row light up from it. Hovering a list row even re-opens the
* card anchored to the first inline marker for that source.
*
* Fully keyboard-navigable (markers are anchors to their footnote), honors
* `prefers-reduced-motion`, and ships nothing but `cn`.
*/
export interface CitationSource {
/** Stable key referenced by `<Cite id>`. */
id: string;
title: string;
/** Absolute URL; its hostname is shown and the row links to it. */
url?: string;
/** Short excerpt shown in the hover card. */
snippet?: string;
}
interface Anchored {
id: string;
anchor: HTMLElement;
}
interface CitationsContextValue {
sources: CitationSource[];
indexOf: (id: string) => number;
openId: string | null;
openNow: (id: string, anchor: HTMLElement) => void;
openFromList: (id: string) => void;
requestClose: () => void;
cancelClose: () => void;
registerMarker: (id: string, el: HTMLElement) => () => void;
markerDomId: (id: string) => string;
listItemDomId: (id: string) => string;
}
const CitationsContext = React.createContext<CitationsContextValue | null>(
null,
);
function useCitations(component: string): CitationsContextValue {
const ctx = React.useContext(CitationsContext);
if (!ctx) {
throw new Error(`<${component}> must be used within <Citations>`);
}
return ctx;
}
/** useLayoutEffect on the client, useEffect on the server (avoids the SSR warning). */
const useIsoLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
function hostnameOf(url?: string): string | null {
if (!url) return null;
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return null;
}
}
export interface CitationsProps extends React.ComponentProps<"div"> {
sources: CitationSource[];
}
export function Citations({
sources,
className,
children,
...props
}: CitationsProps) {
const baseId = React.useId().replace(/[^a-zA-Z0-9]/g, "");
const [open, setOpen] = React.useState<Anchored | null>(null);
// What the card is currently rendering. Lags `open` by one exit so the card
// can fade out on close before it unmounts (see the unmount timer below).
const [card, setCard] = React.useState<Anchored | null>(null);
const [prevOpen, setPrevOpen] = React.useState(open);
const closeTimer = React.useRef<number | undefined>(undefined);
const markers = React.useRef<Map<string, Set<HTMLElement>>>(new Map());
// Sync `card` to `open` during render (the recommended way to react to a
// state change — no effect, no extra commit). On close we leave `card` in
// place so the exit transition has something to animate out.
if (open !== prevOpen) {
setPrevOpen(open);
if (open) setCard(open);
}
// Only the client can portal into document.body; false through SSR + first
// paint, true thereafter — without a setState-in-effect.
const mounted = React.useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
React.useEffect(() => () => window.clearTimeout(closeTimer.current), []);
// Once closed, keep the card mounted just long enough to fade out, then drop
// it. A timer (not transitionend) so it also unmounts under reduced motion.
React.useEffect(() => {
if (open || !card) return;
const t = window.setTimeout(() => setCard(null), 200);
return () => window.clearTimeout(t);
}, [open, card]);
const cancelClose = React.useCallback(() => {
window.clearTimeout(closeTimer.current);
}, []);
const openNow = React.useCallback((id: string, anchor: HTMLElement) => {
window.clearTimeout(closeTimer.current);
setOpen({ id, anchor });
}, []);
// Small grace period so the pointer can travel marker → card without dismissal.
const requestClose = React.useCallback(() => {
window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setOpen(null), 140);
}, []);
const openFromList = React.useCallback(
(id: string) => {
const set = markers.current.get(id);
let anchor: HTMLElement | undefined;
if (set)
for (const el of set) {
anchor = el;
break;
}
if (anchor) openNow(id, anchor);
},
[openNow],
);
const registerMarker = React.useCallback((id: string, el: HTMLElement) => {
let set = markers.current.get(id);
if (!set) {
set = new Set();
markers.current.set(id, set);
}
set.add(el);
return () => {
set!.delete(el);
};
}, []);
const indexOf = React.useCallback(
(id: string) => sources.findIndex((s) => s.id === id),
[sources],
);
const markerDomId = React.useCallback(
(id: string) => `${baseId}-cite-${id}`,
[baseId],
);
const listItemDomId = React.useCallback(
(id: string) => `${baseId}-src-${id}`,
[baseId],
);
// Escape closes the open card.
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const ctx = React.useMemo<CitationsContextValue>(
() => ({
sources,
indexOf,
openId: open?.id ?? null,
openNow,
openFromList,
requestClose,
cancelClose,
registerMarker,
markerDomId,
listItemDomId,
}),
[
sources,
indexOf,
open,
openNow,
openFromList,
requestClose,
cancelClose,
registerMarker,
markerDomId,
listItemDomId,
],
);
const cardSource = card ? sources[indexOf(card.id)] : undefined;
return (
<CitationsContext.Provider value={ctx}>
<div
data-slot="citations"
className={cn("text-sm", className)}
{...props}
>
{children}
</div>
{mounted && card && cardSource
? createPortal(
<CitationCard
key="citation-card"
index={indexOf(card.id) + 1}
source={cardSource}
anchor={card.anchor}
active={open !== null}
onEnter={cancelClose}
onLeave={requestClose}
/>,
document.body,
)
: null}
</CitationsContext.Provider>
);
}
type Side = "top" | "bottom";
interface Placement {
top: number;
left: number;
side: Side;
/** Arrow center offset from the card's left edge, in px. */
arrowX: number;
}
const GAP = 8; // marker ↔ card
const EDGE = 8; // viewport margin
/** Solve a viewport-clamped, edge-flipping placement for `card` next to `anchor`. */
function solvePlacement(anchor: HTMLElement, card: HTMLElement): Placement {
const a = anchor.getBoundingClientRect();
const c = card.getBoundingClientRect();
// clientWidth/Height exclude the scrollbar, so we never clamp into it.
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
const roomBelow = vh - a.bottom;
const roomAbove = a.top;
const side: Side =
c.height + GAP + EDGE > roomBelow && roomAbove > roomBelow
? "top"
: "bottom";
const top = side === "bottom" ? a.bottom + GAP : a.top - GAP - c.height;
const centerX = a.left + a.width / 2;
const left = Math.max(
EDGE,
Math.min(centerX - c.width / 2, vw - c.width - EDGE),
);
const arrowX = Math.max(12, Math.min(centerX - left, c.width - 12));
return { top, left, side, arrowX };
}
interface CitationCardProps {
index: number;
source: CitationSource;
anchor: HTMLElement;
active: boolean;
onEnter: () => void;
onLeave: () => void;
}
function CitationCard({
index,
source,
anchor,
active,
onEnter,
onLeave,
}: CitationCardProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [place, setPlace] = React.useState<Placement | null>(null);
const [shown, setShown] = React.useState(false);
const host = hostnameOf(source.url);
// Measure + place before paint, then re-solve on scroll/resize while open.
useIsoLayoutEffect(() => {
const el = ref.current;
if (!el) return;
let raf = 0;
const solve = () => setPlace(solvePlacement(anchor, el));
solve();
const onScroll = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(solve);
};
// capture:true so scrolling any ancestor (not just window) re-solves.
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
};
// index/source drive the card's size, so re-solve when they change too.
}, [anchor, index, source]);
// Drive the enter/exit transition off `active`, one frame after mount.
React.useEffect(() => {
const raf = requestAnimationFrame(() => setShown(active));
return () => cancelAnimationFrame(raf);
}, [active]);
return (
<div
ref={ref}
role="tooltip"
data-slot="citation-card"
data-side={place?.side}
data-state={shown ? "open" : "closed"}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
style={{
position: "fixed",
top: place?.top ?? 0,
left: place?.left ?? 0,
visibility: place ? "visible" : "hidden",
}}
className={cn(
"z-50 w-72 max-w-[calc(100vw-16px)] rounded-xl border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-800 dark:bg-zinc-900",
"translate-y-1 opacity-0 transition duration-150 ease-[cubic-bezier(0.16,1,0.3,1)]",
"data-[state=closed]:pointer-events-none",
"data-[state=open]:translate-y-0 data-[state=open]:opacity-100",
"motion-reduce:translate-y-0 motion-reduce:transition-none",
)}
>
<span
aria-hidden="true"
data-side={place?.side}
style={{ left: place?.arrowX }}
className={cn(
"absolute size-2 -translate-x-1/2 rotate-45 border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900",
"data-[side=bottom]:-top-1 data-[side=bottom]:border-t data-[side=bottom]:border-l",
"data-[side=top]:-bottom-1 data-[side=top]:border-r data-[side=top]:border-b",
)}
/>
<div className="flex items-center gap-1.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
<span className="text-zinc-950 dark:text-zinc-50">[{index}]</span>
{host ? <span className="truncate">{host}</span> : null}
</div>
<div className="mt-1 leading-snug font-medium text-zinc-950 dark:text-zinc-50">
{source.title}
</div>
{source.snippet ? (
<p className="mt-1.5 line-clamp-3 leading-relaxed text-zinc-500 dark:text-zinc-400">
{source.snippet}
</p>
) : null}
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 font-mono text-xs text-zinc-500 transition-colors hover:text-zinc-950 focus-visible:text-zinc-950 focus-visible:outline-none dark:text-zinc-400 dark:hover:text-zinc-50 dark:focus-visible:text-zinc-50"
>
visit
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-3"
>
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</a>
) : null}
</div>
);
}
export interface CiteProps extends Omit<
React.ComponentProps<"a">,
"href" | "id"
> {
/** Which source (by `id`) this marker references. */
id: string;
}
export function Cite({ id, className, ...props }: CiteProps) {
const ctx = useCitations("Cite");
const ref = React.useRef<HTMLAnchorElement>(null);
const idx = ctx.indexOf(id);
React.useEffect(() => {
const el = ref.current;
if (!el || idx < 0) return;
return ctx.registerMarker(id, el);
}, [ctx, id, idx]);
if (idx < 0) {
return (
<sup
data-slot="citation"
className="ml-0.5 font-mono text-[10px] text-zinc-500 select-none dark:text-zinc-400"
>
[?]
</sup>
);
}
const n = idx + 1;
const isActive = ctx.openId === id;
return (
<a
ref={ref}
id={ctx.markerDomId(id)}
href={`#${ctx.listItemDomId(id)}`}
data-slot="citation"
data-active={isActive ? "" : undefined}
aria-label={`Citation ${n}`}
onPointerEnter={() => ref.current && ctx.openNow(id, ref.current)}
onPointerLeave={ctx.requestClose}
onFocus={() => ref.current && ctx.openNow(id, ref.current)}
onBlur={ctx.requestClose}
className={cn(
"mx-px inline-flex h-[1.15em] min-w-[1.15em] translate-y-[-0.35em] items-center justify-center",
"rounded-[4px] border border-zinc-200 bg-white px-1 align-baseline text-[0.7em] leading-none font-medium text-zinc-500 tabular-nums no-underline dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400",
"transition-colors hover:border-zinc-950/30 hover:bg-zinc-100 hover:text-zinc-950 dark:hover:border-zinc-50/30 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
"focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:focus-visible:ring-zinc-50/50",
"data-[active]:border-zinc-950/40 data-[active]:bg-zinc-100 data-[active]:text-zinc-950 dark:data-[active]:border-zinc-50/40 dark:data-[active]:bg-zinc-800 dark:data-[active]:text-zinc-50",
className,
)}
{...props}
>
{n}
</a>
);
}
export interface CitationListProps extends React.ComponentProps<"div"> {
/** Heading above the list. Pass `null` to hide it. */
label?: React.ReactNode;
}
export function CitationList({
label = "Sources",
className,
...props
}: CitationListProps) {
const ctx = useCitations("CitationList");
return (
<div
data-slot="citation-list"
className={cn(
"mt-4 border-t border-zinc-200 pt-3 dark:border-zinc-800",
className,
)}
{...props}
>
{label != null ? (
<div className="mb-1.5 text-xs font-medium tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
{label}
</div>
) : null}
<ol className="space-y-0.5">
{ctx.sources.map((s, i) => {
const n = i + 1;
const isActive = ctx.openId === s.id;
const host = hostnameOf(s.url);
return (
<li
key={s.id}
id={ctx.listItemDomId(s.id)}
data-slot="citation-source"
data-active={isActive ? "" : undefined}
onPointerEnter={() => ctx.openFromList(s.id)}
onPointerLeave={ctx.requestClose}
onFocus={() => ctx.openFromList(s.id)}
onBlur={ctx.requestClose}
className="scroll-mt-4 rounded-md px-2 py-1.5 transition-colors data-[active]:bg-zinc-100 dark:data-[active]:bg-zinc-800"
>
<div className="flex gap-2">
<span className="mt-px font-mono text-xs text-zinc-500 tabular-nums dark:text-zinc-400">
[{n}]
</span>
<div className="min-w-0 flex-1">
{s.url ? (
<a
href={s.url}
target="_blank"
rel="noreferrer"
className="font-medium text-zinc-950 underline-offset-2 hover:underline focus-visible:underline focus-visible:outline-none dark:text-zinc-50"
>
{s.title}
</a>
) : (
<span className="font-medium text-zinc-950 dark:text-zinc-50">
{s.title}
</span>
)}
{host ? (
<span className="ml-2 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{host}
</span>
) : null}
{s.snippet ? (
<p className="mt-0.5 line-clamp-1 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
{s.snippet}
</p>
) : null}
</div>
</div>
</li>
);
})}
</ol>
</div>
);
}Installation
npx shadcn@latest add https://ui.saumyarex.xyz/r/citation-cards.json1. Install dependencies
npm install clsx tailwind-merge2. Copy the source into your project
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
/**
* Citations — inline source markers with hover cards, the pattern AI answers use
* to footnote their claims.
*
* <Citations sources={sources}>
* <p>
* The grid-rows technique animates height<Cite id="a" /> with no JS
* measurement<Cite id="b" />.
* </p>
* <CitationList />
* </Citations>
*
* `sources` is the data; each `<Cite id>` renders a numbered marker keyed to a
* source, and `<CitationList>` renders the footnote list. Two hard parts:
*
* 1. **Collision-aware positioning.** The hover card is measured and placed with
* `position: fixed` in a portal, so no overflow-clipping ancestor can trap
* it. It prefers to sit below the marker but flips above when the viewport
* runs out of room, and shifts horizontally to stay on-screen while its
* arrow keeps pointing at the marker. It re-solves on scroll and resize.
* 2. **Two-way hover sync.** `openId` is the single shared source of truth —
* hovering/focusing a marker *or* a list row sets it, and both the marker
* and its list row light up from it. Hovering a list row even re-opens the
* card anchored to the first inline marker for that source.
*
* Fully keyboard-navigable (markers are anchors to their footnote), honors
* `prefers-reduced-motion`, and ships nothing but `cn`.
*/
export interface CitationSource {
/** Stable key referenced by `<Cite id>`. */
id: string;
title: string;
/** Absolute URL; its hostname is shown and the row links to it. */
url?: string;
/** Short excerpt shown in the hover card. */
snippet?: string;
}
interface Anchored {
id: string;
anchor: HTMLElement;
}
interface CitationsContextValue {
sources: CitationSource[];
indexOf: (id: string) => number;
openId: string | null;
openNow: (id: string, anchor: HTMLElement) => void;
openFromList: (id: string) => void;
requestClose: () => void;
cancelClose: () => void;
registerMarker: (id: string, el: HTMLElement) => () => void;
markerDomId: (id: string) => string;
listItemDomId: (id: string) => string;
}
const CitationsContext = React.createContext<CitationsContextValue | null>(
null,
);
function useCitations(component: string): CitationsContextValue {
const ctx = React.useContext(CitationsContext);
if (!ctx) {
throw new Error(`<${component}> must be used within <Citations>`);
}
return ctx;
}
/** useLayoutEffect on the client, useEffect on the server (avoids the SSR warning). */
const useIsoLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
function hostnameOf(url?: string): string | null {
if (!url) return null;
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return null;
}
}
export interface CitationsProps extends React.ComponentProps<"div"> {
sources: CitationSource[];
}
export function Citations({
sources,
className,
children,
...props
}: CitationsProps) {
const baseId = React.useId().replace(/[^a-zA-Z0-9]/g, "");
const [open, setOpen] = React.useState<Anchored | null>(null);
// What the card is currently rendering. Lags `open` by one exit so the card
// can fade out on close before it unmounts (see the unmount timer below).
const [card, setCard] = React.useState<Anchored | null>(null);
const [prevOpen, setPrevOpen] = React.useState(open);
const closeTimer = React.useRef<number | undefined>(undefined);
const markers = React.useRef<Map<string, Set<HTMLElement>>>(new Map());
// Sync `card` to `open` during render (the recommended way to react to a
// state change — no effect, no extra commit). On close we leave `card` in
// place so the exit transition has something to animate out.
if (open !== prevOpen) {
setPrevOpen(open);
if (open) setCard(open);
}
// Only the client can portal into document.body; false through SSR + first
// paint, true thereafter — without a setState-in-effect.
const mounted = React.useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
React.useEffect(() => () => window.clearTimeout(closeTimer.current), []);
// Once closed, keep the card mounted just long enough to fade out, then drop
// it. A timer (not transitionend) so it also unmounts under reduced motion.
React.useEffect(() => {
if (open || !card) return;
const t = window.setTimeout(() => setCard(null), 200);
return () => window.clearTimeout(t);
}, [open, card]);
const cancelClose = React.useCallback(() => {
window.clearTimeout(closeTimer.current);
}, []);
const openNow = React.useCallback((id: string, anchor: HTMLElement) => {
window.clearTimeout(closeTimer.current);
setOpen({ id, anchor });
}, []);
// Small grace period so the pointer can travel marker → card without dismissal.
const requestClose = React.useCallback(() => {
window.clearTimeout(closeTimer.current);
closeTimer.current = window.setTimeout(() => setOpen(null), 140);
}, []);
const openFromList = React.useCallback(
(id: string) => {
const set = markers.current.get(id);
let anchor: HTMLElement | undefined;
if (set)
for (const el of set) {
anchor = el;
break;
}
if (anchor) openNow(id, anchor);
},
[openNow],
);
const registerMarker = React.useCallback((id: string, el: HTMLElement) => {
let set = markers.current.get(id);
if (!set) {
set = new Set();
markers.current.set(id, set);
}
set.add(el);
return () => {
set!.delete(el);
};
}, []);
const indexOf = React.useCallback(
(id: string) => sources.findIndex((s) => s.id === id),
[sources],
);
const markerDomId = React.useCallback(
(id: string) => `${baseId}-cite-${id}`,
[baseId],
);
const listItemDomId = React.useCallback(
(id: string) => `${baseId}-src-${id}`,
[baseId],
);
// Escape closes the open card.
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
const ctx = React.useMemo<CitationsContextValue>(
() => ({
sources,
indexOf,
openId: open?.id ?? null,
openNow,
openFromList,
requestClose,
cancelClose,
registerMarker,
markerDomId,
listItemDomId,
}),
[
sources,
indexOf,
open,
openNow,
openFromList,
requestClose,
cancelClose,
registerMarker,
markerDomId,
listItemDomId,
],
);
const cardSource = card ? sources[indexOf(card.id)] : undefined;
return (
<CitationsContext.Provider value={ctx}>
<div
data-slot="citations"
className={cn("text-sm", className)}
{...props}
>
{children}
</div>
{mounted && card && cardSource
? createPortal(
<CitationCard
key="citation-card"
index={indexOf(card.id) + 1}
source={cardSource}
anchor={card.anchor}
active={open !== null}
onEnter={cancelClose}
onLeave={requestClose}
/>,
document.body,
)
: null}
</CitationsContext.Provider>
);
}
type Side = "top" | "bottom";
interface Placement {
top: number;
left: number;
side: Side;
/** Arrow center offset from the card's left edge, in px. */
arrowX: number;
}
const GAP = 8; // marker ↔ card
const EDGE = 8; // viewport margin
/** Solve a viewport-clamped, edge-flipping placement for `card` next to `anchor`. */
function solvePlacement(anchor: HTMLElement, card: HTMLElement): Placement {
const a = anchor.getBoundingClientRect();
const c = card.getBoundingClientRect();
// clientWidth/Height exclude the scrollbar, so we never clamp into it.
const vw = document.documentElement.clientWidth;
const vh = document.documentElement.clientHeight;
const roomBelow = vh - a.bottom;
const roomAbove = a.top;
const side: Side =
c.height + GAP + EDGE > roomBelow && roomAbove > roomBelow
? "top"
: "bottom";
const top = side === "bottom" ? a.bottom + GAP : a.top - GAP - c.height;
const centerX = a.left + a.width / 2;
const left = Math.max(
EDGE,
Math.min(centerX - c.width / 2, vw - c.width - EDGE),
);
const arrowX = Math.max(12, Math.min(centerX - left, c.width - 12));
return { top, left, side, arrowX };
}
interface CitationCardProps {
index: number;
source: CitationSource;
anchor: HTMLElement;
active: boolean;
onEnter: () => void;
onLeave: () => void;
}
function CitationCard({
index,
source,
anchor,
active,
onEnter,
onLeave,
}: CitationCardProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [place, setPlace] = React.useState<Placement | null>(null);
const [shown, setShown] = React.useState(false);
const host = hostnameOf(source.url);
// Measure + place before paint, then re-solve on scroll/resize while open.
useIsoLayoutEffect(() => {
const el = ref.current;
if (!el) return;
let raf = 0;
const solve = () => setPlace(solvePlacement(anchor, el));
solve();
const onScroll = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(solve);
};
// capture:true so scrolling any ancestor (not just window) re-solves.
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
};
// index/source drive the card's size, so re-solve when they change too.
}, [anchor, index, source]);
// Drive the enter/exit transition off `active`, one frame after mount.
React.useEffect(() => {
const raf = requestAnimationFrame(() => setShown(active));
return () => cancelAnimationFrame(raf);
}, [active]);
return (
<div
ref={ref}
role="tooltip"
data-slot="citation-card"
data-side={place?.side}
data-state={shown ? "open" : "closed"}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
style={{
position: "fixed",
top: place?.top ?? 0,
left: place?.left ?? 0,
visibility: place ? "visible" : "hidden",
}}
className={cn(
"z-50 w-72 max-w-[calc(100vw-16px)] rounded-xl border border-zinc-200 bg-white p-3 shadow-lg dark:border-zinc-800 dark:bg-zinc-900",
"translate-y-1 opacity-0 transition duration-150 ease-[cubic-bezier(0.16,1,0.3,1)]",
"data-[state=closed]:pointer-events-none",
"data-[state=open]:translate-y-0 data-[state=open]:opacity-100",
"motion-reduce:translate-y-0 motion-reduce:transition-none",
)}
>
<span
aria-hidden="true"
data-side={place?.side}
style={{ left: place?.arrowX }}
className={cn(
"absolute size-2 -translate-x-1/2 rotate-45 border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900",
"data-[side=bottom]:-top-1 data-[side=bottom]:border-t data-[side=bottom]:border-l",
"data-[side=top]:-bottom-1 data-[side=top]:border-r data-[side=top]:border-b",
)}
/>
<div className="flex items-center gap-1.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
<span className="text-zinc-950 dark:text-zinc-50">[{index}]</span>
{host ? <span className="truncate">{host}</span> : null}
</div>
<div className="mt-1 leading-snug font-medium text-zinc-950 dark:text-zinc-50">
{source.title}
</div>
{source.snippet ? (
<p className="mt-1.5 line-clamp-3 leading-relaxed text-zinc-500 dark:text-zinc-400">
{source.snippet}
</p>
) : null}
{source.url ? (
<a
href={source.url}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 font-mono text-xs text-zinc-500 transition-colors hover:text-zinc-950 focus-visible:text-zinc-950 focus-visible:outline-none dark:text-zinc-400 dark:hover:text-zinc-50 dark:focus-visible:text-zinc-50"
>
visit
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="size-3"
>
<path d="M7 17 17 7M9 7h8v8" />
</svg>
</a>
) : null}
</div>
);
}
export interface CiteProps extends Omit<
React.ComponentProps<"a">,
"href" | "id"
> {
/** Which source (by `id`) this marker references. */
id: string;
}
export function Cite({ id, className, ...props }: CiteProps) {
const ctx = useCitations("Cite");
const ref = React.useRef<HTMLAnchorElement>(null);
const idx = ctx.indexOf(id);
React.useEffect(() => {
const el = ref.current;
if (!el || idx < 0) return;
return ctx.registerMarker(id, el);
}, [ctx, id, idx]);
if (idx < 0) {
return (
<sup
data-slot="citation"
className="ml-0.5 font-mono text-[10px] text-zinc-500 select-none dark:text-zinc-400"
>
[?]
</sup>
);
}
const n = idx + 1;
const isActive = ctx.openId === id;
return (
<a
ref={ref}
id={ctx.markerDomId(id)}
href={`#${ctx.listItemDomId(id)}`}
data-slot="citation"
data-active={isActive ? "" : undefined}
aria-label={`Citation ${n}`}
onPointerEnter={() => ref.current && ctx.openNow(id, ref.current)}
onPointerLeave={ctx.requestClose}
onFocus={() => ref.current && ctx.openNow(id, ref.current)}
onBlur={ctx.requestClose}
className={cn(
"mx-px inline-flex h-[1.15em] min-w-[1.15em] translate-y-[-0.35em] items-center justify-center",
"rounded-[4px] border border-zinc-200 bg-white px-1 align-baseline text-[0.7em] leading-none font-medium text-zinc-500 tabular-nums no-underline dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400",
"transition-colors hover:border-zinc-950/30 hover:bg-zinc-100 hover:text-zinc-950 dark:hover:border-zinc-50/30 dark:hover:bg-zinc-800 dark:hover:text-zinc-50",
"focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:focus-visible:ring-zinc-50/50",
"data-[active]:border-zinc-950/40 data-[active]:bg-zinc-100 data-[active]:text-zinc-950 dark:data-[active]:border-zinc-50/40 dark:data-[active]:bg-zinc-800 dark:data-[active]:text-zinc-50",
className,
)}
{...props}
>
{n}
</a>
);
}
export interface CitationListProps extends React.ComponentProps<"div"> {
/** Heading above the list. Pass `null` to hide it. */
label?: React.ReactNode;
}
export function CitationList({
label = "Sources",
className,
...props
}: CitationListProps) {
const ctx = useCitations("CitationList");
return (
<div
data-slot="citation-list"
className={cn(
"mt-4 border-t border-zinc-200 pt-3 dark:border-zinc-800",
className,
)}
{...props}
>
{label != null ? (
<div className="mb-1.5 text-xs font-medium tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
{label}
</div>
) : null}
<ol className="space-y-0.5">
{ctx.sources.map((s, i) => {
const n = i + 1;
const isActive = ctx.openId === s.id;
const host = hostnameOf(s.url);
return (
<li
key={s.id}
id={ctx.listItemDomId(s.id)}
data-slot="citation-source"
data-active={isActive ? "" : undefined}
onPointerEnter={() => ctx.openFromList(s.id)}
onPointerLeave={ctx.requestClose}
onFocus={() => ctx.openFromList(s.id)}
onBlur={ctx.requestClose}
className="scroll-mt-4 rounded-md px-2 py-1.5 transition-colors data-[active]:bg-zinc-100 dark:data-[active]:bg-zinc-800"
>
<div className="flex gap-2">
<span className="mt-px font-mono text-xs text-zinc-500 tabular-nums dark:text-zinc-400">
[{n}]
</span>
<div className="min-w-0 flex-1">
{s.url ? (
<a
href={s.url}
target="_blank"
rel="noreferrer"
className="font-medium text-zinc-950 underline-offset-2 hover:underline focus-visible:underline focus-visible:outline-none dark:text-zinc-50"
>
{s.title}
</a>
) : (
<span className="font-medium text-zinc-950 dark:text-zinc-50">
{s.title}
</span>
)}
{host ? (
<span className="ml-2 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{host}
</span>
) : null}
{s.snippet ? (
<p className="mt-0.5 line-clamp-1 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
{s.snippet}
</p>
) : null}
</div>
</div>
</li>
);
})}
</ol>
</div>
);
}import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
Citations.sources | CitationSource[] | — | The source data: { id, title, url?, snippet? }. Marker numbers follow this order. |
Cite.id | string | — | References a source by id; renders the numbered inline marker (an anchor to its footnote). |
CitationList.label | React.ReactNode | "Sources" | Heading above the footnote list. Pass null to hide it. |
CitationSource | { id; title; url?; snippet? } | — | Shape of each source. url drives the hostname label and outbound link; snippet shows in the card. |