AI
Citation
A numbered source marker inside assistant prose with a hover card, plus the matching source list.
- Built on
Popover APIpopover="hint"CSS anchor positioning- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.0 kB
- Dependencies
- React
Build 412 lowered the pool limit to 201ChangelogRelease notes for build 412pool.max now defaults to 20 connections., while peak traffic needs about 352Metricsapi-gateway connections, last 7 daysPeak of 34 open connections on Tuesday at 14:02..
Code
import { Citation } from "@/components/equip/citation";
export default function Demo() {
return (
<p style={{ width: 224, margin: 0, fontSize: "var(--eq-text-m)", lineHeight: 1.55 }}>
Build 412 lowered the pool limit to 20
<Citation
index={1}
href="#changelog"
title="Release notes for build 412"
source="Changelog"
snippet="pool.max now defaults to 20 connections."
/>
, while peak traffic needs about 35
<Citation
index={2}
href="#dashboard"
title="api-gateway connections, last 7 days"
source="Metrics"
snippet="Peak of 34 open connections on Tuesday at 14:02."
/>
.
</p>
);
}Examples
Answer with a source list
Markers and list entries share numbers, and each marker is announced as "Source 1" followed by the title.
The outage began when build 412 reached eu-west-11Status pageIncident 2291: elevated 5xx on api-gatewayErrors began at 14:02 UTC, two minutes after the rollout started.. Rolling back to 411 restored the error rate within four minutes2Engineering wikiRollback runbook, and the fix ships in build 4133Pull request 8812Raise pool.max to 50Restores the previous limit and adds an alert at 80% use..
Code
import { Citation, CitationList, CitationListItem } from "@/components/equip/citation";
export default function Example() {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16, width: "100%", maxWidth: 520, minWidth: 0, lineHeight: 1.55 }}>
<p style={{ margin: 0 }}>
The outage began when build 412 reached eu-west-1
<Citation
index={1}
href="#incident"
title="Incident 2291: elevated 5xx on api-gateway"
source="Status page"
snippet="Errors began at 14:02 UTC, two minutes after the rollout started."
/>
. Rolling back to 411 restored the error rate within four minutes
<Citation index={2} href="#runbook" title="Rollback runbook" source="Engineering wiki" />
, and the fix ships in build 413
<Citation
index={3}
href="#pr"
title="Raise pool.max to 50"
source="Pull request 8812"
snippet="Restores the previous limit and adds an alert at 80% use."
/>
.
</p>
<CitationList>
<CitationListItem index={1} href="#incident" title="Incident 2291: elevated 5xx on api-gateway" source="Status page" />
<CitationListItem index={2} href="#runbook" title="Rollback runbook" source="Engineering wiki" />
<CitationListItem index={3} href="#pr" title="Raise pool.max to 50" source="Pull request 8812" />
</CitationList>
</div>
);
}One source cited twice
Reuse the same index when two claims rest on one document; the list keeps a single entry for it.
Check replication lag before promoting the replica1Engineering wikiDatabase failover runbookPromote the replica only after replication lag is under 5 seconds.. If lag stays above five seconds, wait rather than force the failover1Engineering wikiDatabase failover runbookPromote the replica only after replication lag is under 5 seconds..
Code
import { Citation } from "@/components/equip/citation";
export default function Example() {
const runbook = {
href: "#runbook",
title: "Database failover runbook",
source: "Engineering wiki",
snippet: "Promote the replica only after replication lag is under 5 seconds.",
};
return (
<p style={{ margin: 0, maxWidth: 480, lineHeight: 1.55 }}>
Check replication lag before promoting the replica
<Citation index={1} {...runbook} />
. If lag stays above five seconds, wait rather than force the failover
<Citation index={1} {...runbook} />.
</p>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
index | number | none | The number shown, matching the entry in CitationList. |
href | string | none | Where the source lives. |
title | string | none | Title of the source. Part of the accessible name, "Source 1: title". |
source | string | none | Site or document name shown in the card. |
snippet | string | none | The passage the claim rests on, shown in the card. |
...rest | ComponentProps<"a"> | none | Every native anchor attribute, such as target or rel. |
CitationList heading | ReactNode | "Sources" | Label above the list; also names the list. |
CitationListItem | { index, href, title, source? } | none | One numbered entry, a link to the source. |
Anatomy
| marker | .eq-citation | The numbered chip link. |
|---|---|---|
| card | .eq-citation-card | A hint popover anchored to the marker. |
| card source | .eq-citation-card-source | |
| card title | .eq-citation-card-title | |
| card snippet | .eq-citation-card-snippet | |
| list | .eq-citation-list | |
| list heading | .eq-citation-list-heading | |
| list items | .eq-citation-list-items | The ol. |
| list item | .eq-citation-list-item | |
| list index | .eq-citation-list-index | |
| list link | .eq-citation-list-link | |
| list title | .eq-citation-list-title | |
| list source | .eq-citation-list-source |
Keyboard
| Tab | Focuses a marker and shows its card. |
|---|---|
| Enter | Opens the source. |
| Escape | Hides the card while the marker keeps focus. |
Notes
- Each marker is a real link named "Source 1: title", and the card's text is its description, so the snippet is heard on focus.
- The card opens on hover and on keyboard focus, not on touch, where a tap simply follows the link.
- The pointer can move from the marker into the card without it closing.
- Markers are 16px tall so they never push lines apart, with a 24px hit area around them.
- The card needs the Popover API and CSS anchor positioning. Where either is missing the marker carries a title attribute with the title, source and snippet instead; where popover="hint" is missing it behaves as a manual popover.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/citation.tsx171 lines
"use client";
import {
useEffect,
useId,
useRef,
useState,
type ComponentProps,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
} from "react";
import "./citation.css";
export type CitationProps = Omit<ComponentProps<"a">, "title" | "href"> & {
/** The number shown, matching the entry in CitationList. */
index: number;
href: string;
/** Title of the source document or page. */
title: string;
/** Site or document name, e.g. "status.example.com". */
source?: string;
/** The passage the claim rests on. */
snippet?: string;
};
/** How long the card waits before closing, so the pointer can cross from the marker into it. */
const CLOSE_DELAY = 120;
export function Citation({
index,
href,
title,
source,
snippet,
className,
style,
onPointerEnter,
onPointerLeave,
onFocus,
onBlur,
onKeyDown,
...rest
}: CitationProps) {
const id = useId();
const cardId = `${id}-card`;
const anchor = `--eq-citation-${id.replace(/[^a-zA-Z0-9]/g, "")}`;
const cardRef = useRef<HTMLSpanElement>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// The server renders the title attribute; the card replaces it once the browser can place it.
const [enhanced, setEnhanced] = useState(false);
useEffect(() => {
const canPop = typeof HTMLElement.prototype.showPopover === "function";
setEnhanced(canPop && CSS.supports("anchor-name", "--a"));
return () => clearTimeout(closeTimer.current);
}, []);
const open = () => {
clearTimeout(closeTimer.current);
const card = cardRef.current;
if (enhanced && card && !card.matches(":popover-open")) card.showPopover();
};
const close = (now = false) => {
clearTimeout(closeTimer.current);
const hide = () => {
const card = cardRef.current;
if (card?.matches(":popover-open")) card.hidePopover();
};
if (now) hide();
else closeTimer.current = setTimeout(hide, CLOSE_DELAY);
};
const fallbackTitle = [title, source, snippet].filter(Boolean).join("\n");
return (
<>
<a
{...rest}
href={href}
className={["eq-citation", className].filter(Boolean).join(" ")}
style={{ "--_anchor": anchor, ...style } as CSSProperties}
aria-label={`Source ${index}: ${title}`}
aria-describedby={snippet || source ? cardId : undefined}
title={enhanced ? undefined : fallbackTitle}
onPointerEnter={(event) => {
onPointerEnter?.(event);
if (event.pointerType !== "touch") open();
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
close();
}}
onFocus={(event) => {
onFocus?.(event);
open();
}}
onBlur={(event) => {
onBlur?.(event);
close(true);
}}
onKeyDown={(event: KeyboardEvent<HTMLAnchorElement>) => {
onKeyDown?.(event);
if (event.key === "Escape" && cardRef.current?.matches(":popover-open")) {
event.preventDefault();
event.stopPropagation();
close(true);
}
}}
>
{index}
</a>
<span
ref={cardRef}
id={cardId}
popover="hint"
className="eq-citation-card"
style={{ "--_anchor": anchor } as CSSProperties}
onPointerEnter={open}
onPointerLeave={() => close()}
>
{source && <span className="eq-citation-card-source">{source}</span>}
<span className="eq-citation-card-title">{title}</span>
{snippet && <span className="eq-citation-card-snippet">{snippet}</span>}
</span>
</>
);
}
export type CitationListProps = ComponentProps<"div"> & {
/** Heading above the list. */
heading?: ReactNode;
};
export function CitationList({ heading = "Sources", className, children, ...rest }: CitationListProps) {
const headingId = useId();
return (
<div {...rest} className={["eq-citation-list", className].filter(Boolean).join(" ")}>
<p className="eq-citation-list-heading" id={headingId}>
{heading}
</p>
{/* role="list" keeps list semantics in Safari once the markers are removed. */}
<ol role="list" aria-labelledby={headingId} className="eq-citation-list-items">
{children}
</ol>
</div>
);
}
export type CitationListItemProps = Omit<ComponentProps<"li">, "title" | "value"> & {
/** Matches the index of the Citation markers that point here. */
index: number;
href: string;
title: string;
source?: string;
};
export function CitationListItem({ index, href, title, source, className, ...rest }: CitationListItemProps) {
return (
<li {...rest} value={index} className={["eq-citation-list-item", className].filter(Boolean).join(" ")}>
<span className="eq-citation-list-index">
{index}
</span>
<a className="eq-citation-list-link" href={href}>
<span className="eq-citation-list-title">{title}</span>
{source && <span className="eq-citation-list-source">{source}</span>}
</a>
</li>
);
}components/equip/citation.css203 lines
@layer equip {
/* The marker: a small numbered chip that sits on the text line without pushing it apart. */
.eq-citation {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
margin-inline: 2px;
padding-inline: var(--eq-space-1);
vertical-align: text-top;
border-radius: var(--eq-radius-s);
background: var(--eq-tint-strong);
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
text-decoration: none;
anchor-name: var(--_anchor);
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
/* Visible at 16px, hit target at 24px. */
.eq-citation::after {
content: "";
position: absolute;
inset: -4px;
}
@media (hover: hover) {
.eq-citation:hover {
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
}
.eq-citation:active {
scale: var(--eq-press);
}
.eq-citation:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-citation-card {
display: none;
position: fixed;
position-anchor: var(--_anchor);
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
inset: auto;
box-sizing: border-box;
width: max-content;
max-width: min(20rem, calc(100vw - 2 * var(--eq-space-4)));
margin: var(--eq-space-1) 0;
padding: var(--eq-space-4);
overflow: visible;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.45 var(--eq-font);
text-align: start;
transform-origin: top left;
opacity: 0;
scale: 0.96;
translate: 0 4px;
/* Leaves faster than it arrives. */
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
transition-behavior: allow-discrete;
}
.eq-citation-card:popover-open {
display: flex;
flex-direction: column;
gap: 2px;
opacity: 1;
scale: 1;
translate: 0;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur-move) allow-discrete,
overlay var(--eq-dur-move) allow-discrete;
transition-behavior: allow-discrete;
}
@starting-style {
.eq-citation-card:popover-open {
opacity: 0;
scale: 0.96;
translate: 0 4px;
}
}
.eq-citation-card-source {
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
}
.eq-citation-card-title {
font-weight: var(--eq-weight-strong);
}
.eq-citation-card-snippet {
margin-block-start: var(--eq-space-1);
padding-inline-start: var(--eq-space-2);
border-inline-start: 2px solid var(--eq-line-strong);
color: var(--eq-ink-2);
}
.eq-citation-list {
min-width: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-citation-list-heading {
margin: 0 0 var(--eq-space-1);
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
}
.eq-citation-list-items {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.eq-citation-list-item {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-width: 0;
min-height: var(--eq-h-s);
}
.eq-citation-list-index {
flex: none;
display: inline-grid;
place-items: center;
min-width: 16px;
height: 16px;
padding-inline: var(--eq-space-1);
box-sizing: border-box;
border-radius: var(--eq-radius-s);
background: var(--eq-tint-strong);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
}
.eq-citation-list-link {
display: inline-flex;
align-items: baseline;
gap: var(--eq-space-2);
min-width: 0;
color: var(--eq-ink);
text-decoration: none;
border-radius: var(--eq-radius-s);
}
@media (hover: hover) {
.eq-citation-list-link:hover .eq-citation-list-title {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 0.2em;
}
}
.eq-citation-list-link:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-citation-list-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-citation-list-source {
flex: none;
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
}
}