Citation Popover
Pointing at a citation marker floats the source card up beside it, and leaving puts it away.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { Fragment, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Citation Popover
*
* Citation markers inside a generated answer. Pointing at one floats the
* source it came from up beside the marker; leaving puts it away.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so it reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `answer`, `sources`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CitationSource = {
/** The number printed in the marker. */
index: number;
title: string;
/** Where it came from — a domain, a workspace, a file path. */
origin: string;
/** The sentence the answer leaned on. */
quote: string;
};
export type CitationPopoverProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Answer text. `[1]`-style markers become citation chips. */
answer?: string;
/** Sources, matched to markers by `index`. */
sources?: CitationSource[];
/** Accent for the marker and the source rule. */
color?: string;
};
type VariantConfig = {
/** px the card floats up as it arrives. */
riseY: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** Fade for the card, kept shorter than the travel. */
fadeSeconds: number;
/** px the marker itself lifts while active. */
markerLift: number;
};
// Damping ratios (ζ = damping / 2√stiffness) stay at or above 0.8. The
// card is dense text arriving under the reader's pointer; anything that
// overshoots twice has to be re-read, which defeats a citation.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.07 — no overshoot, minimal travel. For prose thick with markers.
subtle: {
riseY: 2,
spring: { type: "spring", stiffness: 500, damping: 48 },
fadeSeconds: 0.12,
markerLift: 0,
},
// ζ ≈ 0.93 — lands clean. The all-purpose setting.
default: {
riseY: 8,
spring: { type: "spring", stiffness: 420, damping: 38 },
fadeSeconds: 0.16,
markerLift: 1,
},
// ζ ≈ 0.82 — one soft settle, more travel, for a research surface
// where the sources are the point.
playful: {
riseY: 16,
spring: { type: "spring", stiffness: 350, damping: 31 },
fadeSeconds: 0.2,
markerLift: 2,
},
};
const SAMPLE_ANSWER =
"Support volume fell 18% in the eight weeks after the self-serve refund flow shipped [1], and median first response is now under four minutes on weekday shifts [2].";
const SAMPLE_SOURCES: CitationSource[] = [
{
index: 1,
title: "Q3 Support Review",
origin: "operations / quarterly",
quote: "Ticket volume dropped from 4,120 to 3,378 over the eight weeks following launch.",
},
{
index: 2,
title: "Response Time Report",
origin: "analytics / service desk",
quote: "Median first response held at 3m 42s across weekday shifts in September.",
},
];
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* that is correctly toned in either theme. Nothing to configure. */
/** Card width, and how far it hangs past the marker on its anchored
* side. Named because the open() measurement has to agree with the
* style below — a card that measures one width and renders another
* flips on the wrong side. */
const CARD_WIDTH = 236;
const CARD_OVERHANG = 6;
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function CitationPopover({
variant = "default",
answer = SAMPLE_ANSWER,
sources = SAMPLE_SOURCES,
color = "#7C7CF0",
}: CitationPopoverProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Which marker is open, and which side its card hangs from. The side
// is stored with the marker because it is decided by measurement at
// the moment of opening, not by a rule that can be evaluated earlier.
const [active, setActive] = useState<{ index: number; anchorRight: boolean } | null>(null);
/**
* Open the card for a marker, choosing the side that keeps it inside
* the column.
*
* Measured from the marker's real position rather than inferred from
* its number. An earlier version flipped markers in the second half of
* the list, which is a guess about where text happens to wrap: with
* two sources it never flipped at all, and the card ran 41px past the
* column with the source text cut off mid-number.
*/
const open = (index: number, marker: HTMLElement) => {
const column = marker.closest("p")?.getBoundingClientRect();
const box = marker.getBoundingClientRect();
const overflowsRight = column
? box.left - CARD_OVERHANG + CARD_WIDTH > column.right
: false;
setActive({ index, anchorRight: overflowsRight });
};
// Split on the markers so each one can carry its own anchor. Plain
// string in, no markup to sanitize.
const parts = answer.split(/(\[\d+\])/g);
return (
<p
style={{
width: 320,
margin: 0,
fontSize: 13.5,
lineHeight: 1.75,
opacity: 0.86,
}}
>
{parts.map((part, partIndex) => {
const marker = /^\[(\d+)\]$/.exec(part);
if (!marker) return <Fragment key={partIndex}>{part}</Fragment>;
const index = Number(marker[1]);
const source = sources.find((entry) => entry.index === index);
if (!source) return <Fragment key={partIndex}>{part}</Fragment>;
const isActive = active?.index === index;
const anchorRight = active?.anchorRight ?? false;
return (
<span
key={partIndex}
style={{ position: "relative", display: "inline-block" }}
>
<motion.button
type="button"
aria-expanded={isActive}
aria-label={`Source ${index}: ${source.title}`}
onPointerEnter={(event) => open(index, event.currentTarget)}
onPointerLeave={() => setActive(null)}
onFocus={(event) => open(index, event.currentTarget)}
onBlur={() => setActive(null)}
animate={{ y: isActive && !reduceMotion ? -cfg.markerLift : 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{
// The tint is a CSS transition rather than an animated
// value: these neutrals are color-mix() surfaces, which
// the browser interpolates and a JS color parser does not.
background: isActive ? tone(14) : tone(8),
transition: "background-color 180ms ease-out",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minWidth: 16,
height: 15,
margin: "0 1px",
padding: "0 4px",
borderRadius: 5,
border: "none",
color,
font: "inherit",
fontSize: 10.5,
fontWeight: 700,
lineHeight: 1,
verticalAlign: "text-top",
fontVariantNumeric: "tabular-nums",
cursor: "pointer",
}}
>
{index}
</motion.button>
<AnimatePresence>
{isActive && (
<motion.span
key="card"
role="tooltip"
// Reduced motion: the card is placed, not floated —
// opacity only, no travel. The source still appears.
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.riseY }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.riseY * 0.5 }}
transition={{
y: cfg.spring,
opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
}}
style={{
position: "absolute",
bottom: "calc(100% + 9px)",
left: anchorRight ? "auto" : -CARD_OVERHANG,
right: anchorRight ? -CARD_OVERHANG : "auto",
zIndex: 2,
display: "block",
width: CARD_WIDTH,
padding: "10px 12px",
borderRadius: 12,
// A popover floats over the text it explains, so it
// needs opaque ground rather than a tinted one.
// `Canvas`/`CanvasText` are the CSS system colors for
// page background and page text: they follow the host
// app's light or dark surface without configuration,
// and every tone() inside the card is mixed from
// CanvasText as a result.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 14px 34px rgba(0,0,0,0.18)",
textAlign: "left",
lineHeight: 1.45,
pointerEvents: "none",
}}
>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 10.5,
fontWeight: 650,
letterSpacing: "0.06em",
textTransform: "uppercase",
color,
}}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path
d="M3 1.5h4l2.5 2.5v6.5H3z"
stroke="currentColor"
strokeWidth="1.1"
strokeLinejoin="round"
/>
<path
d="M6.8 1.6v2.6h2.6"
stroke="currentColor"
strokeWidth="1.1"
strokeLinejoin="round"
/>
</svg>
Source {index}
</span>
<span
style={{
display: "block",
marginTop: 5,
fontSize: 12.5,
fontWeight: 650,
}}
>
{source.title}
</span>
<span
style={{ display: "block", fontSize: 11, opacity: 0.5 }}
>
{source.origin}
</span>
<span
style={{
display: "block",
marginTop: 7,
paddingTop: 7,
borderTop: `1px solid ${tone(12)}`,
fontSize: 11.5,
opacity: 0.62,
}}
>
{source.quote}
</span>
</motion.span>
)}
</AnimatePresence>
</span>
);
})}
</p>
);
}About this pattern
A generated answer is only as trustworthy as the reader's ability to check it, and the check has to cost nothing. The marker is a real button, so pointer and keyboard both reach it; the card arrives on a spring that travels but never scales, because a source quote that grows into place is harder to read than one that simply appears. Late markers anchor to their right edge so a card near the end of a line stays inside the column instead of hanging off it.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
Numbered markers in the answer that reveal the cited page on hover.
Related patterns
- Context Pill AttachA dragged file is caught by the prompt bar and lands inside it as a context pill.
- Reasoning Steps UnfoldA collapsed trace line opens into numbered reasoning steps, each arriving as the panel grows.
- Source Fan OutA tilted deck of reference cards fans apart to show what an answer was grounded in.