"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/**
* Keep:
* - Every word, marker and chip is in the markup from the start and only shown as the run reaches
* it, so playing never changes the card's height.
* - The run is planned up front in ms (`buildPlan`), and one timer jumps a clock kept outside React
* from event to event; off screen or in a hidden tab it stops and picks up where it was.
* - Writing never waits for the numbers. A flying number re-measures its marker every frame and
* hands off to it in the frame it lands, and a slower flight pushes the finish back to match.
* - A link holds for a moment between claims, so moving across lines doesn't flicker; a source opens
* after the hover intent and closes only once the pointer has left both chip and card.
* - Reduced motion, read only after hydration, shows the finished answer at once with no dealing,
* streaming or flights. Links and source cards still work, and cards fade without growing.
*/
const TIMING = {
/** After play, before the first source is read, in ms: the status line arrives with it. */
start: 0,
/** What the reading aims to take, whatever the count, in ms. */
spread: 900,
/** Between reads, at least and at most, in ms. */
step: [35, 90],
/** Between the last read and the first word, in ms. */
hold: 240,
/** How fast the answer writes itself, in words a second (the `speed` prop). */
speed: 45,
/** A word fading in, in seconds; the run waits for the last one. */
word: 0.12,
/** The same fade, as Tailwind classes. Keep it equal to `word`. */
wordFade: "duration-[120ms] ease-out",
/** After a claim's last word, before its number lifts off, in ms. */
lag: 60,
/** Between the numbers of one claim, in ms. */
stagger: 70,
/** From the last lift-off until the run counts as done, at the default flight, in ms. */
settle: 520,
/** A number flying into its marker (the `flightDuration` and `flightBounce` props). */
fly: { type: "spring", visualDuration: 0.45, bounce: 0.16, restDelta: 0.002, restSpeed: 0.02 },
/** The ring on a chip as its number lifts off. */
pulse: { duration: 0.5, ease: [0.22, 1, 0.36, 1] },
/** The question and the status line rising in. */
rise: { duration: 0.3, ease: [0.22, 1, 0.36, 1] },
/** The old answer leaving for good. */
fold: { duration: 0.2, ease: [0.55, 0, 0.75, 0.4] },
/** The card easing to the new answer's height. */
height: { type: "spring", visualDuration: 0.34, bounce: 0 },
/** A link holding before it clears, in ms. */
linger: 100,
/** On a chip before its source opens, in ms. */
intent: 120,
/** After leaving chip and card, before the source closes, in ms. */
leave: 250,
/** A source card growing out of its chip. */
open: { type: "spring", visualDuration: 0.3, bounce: 0.1 },
/** A source card closing, and its fade in and out under reduced motion. */
close: { duration: 0.15, ease: "easeOut" },
/** A claim lighting up or dimming (`on`) and clearing (`off`), as Tailwind classes. */
link: { ease: "ease-out", on: "duration-75", off: "duration-150" },
/** A chip lighting up or dimming (`on`), and being dealt or clearing (`off`), as Tailwind classes. */
chip: { ease: "ease-out", on: "duration-75", off: "duration-200" },
/** The "+N more" chip being dealt, as Tailwind classes. */
more: "duration-200 ease-out",
/** Markers, chips and their numbers changing tint, as a Tailwind class. */
tint: "duration-150",
/** The footer's rule showing with the first read, as a Tailwind class. */
rule: "duration-200",
/** The placeholder lines fading as the first word lands, as Tailwind classes. */
skeleton: "duration-200 ease-out",
} as const;
/* ─────────────────────────────────────────────────────────
* FOOTNOTE STORYBOARD
*
* rest the question with a small badge, three faint lines
* where the answer will be, and an empty footer. Every
* word is already in the page, hidden: playing only shows
* them, so the card never changes height. Without play
* (or with reduced motion) the finished answer shows
* view a third of it in view: the status line rises 6px with
* a spinner and "Read 1 source". Sources are read one at
* a time, 35–90ms apart (about 0.9s in all): each deals
* its chip into the footer (0.96 → 1 and a fade, 0.2s)
* and ticks the count. Reads past the last chip only
* tick "+N more"
* write 0.24s after the last read the faint lines fade and the
* answer streams in at 45 words a second, each word
* fading in over 0.12s
* cite 60ms after a cited claim's last word lands, the number
* in its source's chip lights up and lifts out: a ring
* pulses on the chip (0.5s) while the number flies up in
* an arc that peaks 26px over the straight line, grows
* 22% at the top and settles into the claim's marker on
* a 0.45s spring with a little give. Two sources on one
* claim leave 70ms apart. Writing never waits for them
* done the spinner turns into a check
* link point at a claim or its marker: the claim takes an
* accent tint, its chips rise 2px and ring in the accent,
* and every other claim fades to 45%. Point at a chip and
* the claims it backs light up instead. On in 75ms, off
* in 150ms, and it holds 100ms between claims so moving
* across lines doesn't flicker
* preview rest on a chip for 120ms (or focus it, or tap it) and
* a source card grows out of it (0.92 → 1, 0.3s spring,
* a touch of give): the site, the title and the passage
* with the quoted part marked. It opens above the chip
* when there's room and below when there isn't, and
* closes 250ms after the pointer leaves chip and card
* again a new question folds the old answer up and out as one
* block (4px, 0.2s), the card eases to its new height
* and the new question rises in and plays from reading
* touch tap a claim to link it, tap a chip or a marker to open
* its source, tap outside to clear
* keys Tab walks the markers in reading order, then the
* chips. Enter on a marker moves focus to its chip and
* opens its source; focusing a chip opens it too, and
* Space toggles it. Esc closes and keeps focus on the chip
* sleep off screen or in a hidden tab the clock stops and
* picks up where it was
* reduced no dealing, streaming or flights: the finished answer
* shows at once. Links and source cards still work, and
* cards fade in without growing
* ───────────────────────────────────────────────────────── */
const NOTE = {
arc: 26, // px the flight peaks over a straight line
grow: 0.22, // extra scale at the top of the arc
lift: 6, // px the question and status rise in from
gap: 8, // px between a chip and its open source
} as const;
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
const smoothstep = (a: number, b: number, v: number) => {
const x = clamp((v - a) / (b - a), 0, 1);
return x * x * (3 - 2 * x);
};
const monogram = (site: string) =>
site
.replace(/^(https?:\/\/)?(www\.)?/, "")
.charAt(0)
.toUpperCase();
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
const SKELETON = ["94%", "100%", "58%"]; // widths of the lines held for the answer while it reads
/* ── Types ──────────────────────────────────────────────── */
export interface FootnoteSource {
/** What a claim's `cite` points at. */
id: string;
/** The site's name or domain, shown on the chip: "docs.plinth.so". */
site: string;
/** The page's title, shown when the source opens. */
title: string;
/** Where the source lives. The chip and the open card's title link to it. */
href?: string;
/** The passage the answer leans on, shown when the source opens. */
snippet?: string;
/** The words of `snippet` to mark, when they appear in it. */
quote?: string;
}
export interface FootnoteClaim {
/** A sentence or a clause of the answer. */
text: string;
/** The `id` of the source that backs it, or several. Leave it out for a claim with no source. */
cite?: string | string[];
}
export interface FootnoteProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** The question, at the top of the card. A new question folds the old answer away and plays the new one. */
question: string;
/** The answer, claim by claim, in reading order. Each claim gets a marker for every source it cites. */
answer: FootnoteClaim[];
/** The sources it read. Cited ones are numbered in the order the answer first cites them; the rest follow with a monogram. */
sources: FootnoteSource[];
/** How many sources it says it read, when that's more than you list. The extra ones show as "+N more". */
read?: number;
/** When it plays: `"view"` once a third of it is on screen, `true` as soon as it mounts, `false` never (the finished answer shows). */
play?: boolean | "view";
/** How fast the answer writes itself, in words a second. */
speed?: number;
/** Pixels each source's number arcs up over a straight line on its way to the claim. */
arc?: number;
/** Seconds a number takes to fly from its chip to the claim. */
flightDuration?: number;
/** How much a number overshoots its marker and settles, from 0 (none) up. */
flightBounce?: number;
/** The badge beside the question, like your product's mark. A small spark when unset. */
avatar?: React.ReactNode;
/** The status line's text, from the number of sources read so far and whether reading has finished. */
status?: (read: number, done: boolean) => React.ReactNode;
/** Called when an answer has finished: written, every number landed. At once when it doesn't play. */
onComplete?: () => void;
}
const defaultStatus = (read: number) => `Read ${plural(read, "source")}`;
/* ── Plan ───────────────────────────────────────────────── */
// Everything the run does, as times in ms from the moment it starts playing.
type Mark = { id: string; num: number };
type PlanClaim = { words: string[]; start: number; marks: Mark[] };
type PlanFlight = { claim: number; id: string; num: number; at: number };
type Plan = {
claims: PlanClaim[];
order: { source: FootnoteSource; num?: number }[];
words: number;
reads: number;
readAt: number[];
writeAt: number;
per: number;
flights: PlanFlight[];
doneAt: number;
events: number[];
};
function buildPlan(
answer: FootnoteClaim[],
sources: FootnoteSource[],
read: number | undefined,
speed: number,
settle: number,
): Plan {
const known = new Set(sources.map((s) => s.id));
const nums = new Map<string, number>();
let words = 0;
const claims = answer.map((claim) => {
const ids = (Array.isArray(claim.cite) ? claim.cite : claim.cite ? [claim.cite] : []).filter(
(id, i, all) => known.has(id) && all.indexOf(id) === i,
);
const marks = ids.map((id) => {
if (!nums.has(id)) nums.set(id, nums.size + 1);
return { id, num: nums.get(id)! };
});
const list = claim.text.trim().split(/\s+/).filter(Boolean);
const planned = { words: list, start: words, marks };
words += list.length;
return planned;
});
const order = [
...sources.filter((s) => nums.has(s.id)).sort((a, b) => nums.get(a.id)! - nums.get(b.id)!),
...sources.filter((s) => !nums.has(s.id)),
].map((source) => ({ source, num: nums.get(source.id) }));
const reads = Math.max(read ?? 0, sources.length);
const step = clamp(TIMING.spread / Math.max(1, reads), TIMING.step[0], TIMING.step[1]);
const readAt = Array.from({ length: reads }, (_, k) => Math.round(TIMING.start + k * step));
const writeAt = (readAt[reads - 1] ?? TIMING.start) + TIMING.hold;
const per = 1000 / Math.max(1, speed);
const flights: PlanFlight[] = [];
claims.forEach((claim, i) => {
if (!claim.words.length) return;
const landed = writeAt + (claim.start + claim.words.length - 1) * per;
claim.marks.forEach((mark, m) => {
flights.push({ claim: i, id: mark.id, num: mark.num, at: Math.round(landed + TIMING.lag + m * TIMING.stagger) });
});
});
const lastWord = writeAt + Math.max(0, words - 1) * per + TIMING.word * 1000;
const doneAt = Math.round(Math.max(lastWord, ...flights.map((f) => f.at + settle)));
const events = new Set<number>([...readAt, ...flights.map((f) => f.at), doneAt]);
for (let g = 0; g < words; g++) events.add(Math.round(writeAt + g * per));
return {
claims,
order,
words,
reads,
readAt,
writeAt,
per,
flights,
doneAt,
events: [...events].sort((a, b) => a - b),
};
}
/* ── Run store ──────────────────────────────────────────── */
// The clock, the numbers in flight and the ones that have landed, for the answer on screen. It
// lives outside React so timers and animation callbacks can move it without effects setting state.
type Point = { x: number; y: number };
type Flight = { key: string; num: number; from: Point; to: Point };
type Content = { key: string; question: string; answer: FootnoteClaim[]; sources: FootnoteSource[]; read?: number };
type RunState = {
run: number;
key: string;
data: Content;
t: number;
landed: ReadonlySet<string>;
flying: readonly Flight[];
folding: boolean;
};
function createRun(initial: Content) {
let state: RunState = { run: 0, key: initial.key, data: initial, t: 0, landed: new Set(), flying: [], folding: false };
const listeners = new Set<() => void>();
const emit = (next: RunState) => {
state = next;
listeners.forEach((listener) => listener());
};
return {
get: () => state,
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
advance(t: number, launch: Flight[], landNow: string[]) {
emit({
...state,
t,
flying: launch.length ? [...state.flying, ...launch] : state.flying,
landed: landNow.length ? new Set([...state.landed, ...landNow]) : state.landed,
});
},
land(key: string) {
if (!state.flying.some((flight) => flight.key === key)) return;
emit({
...state,
flying: state.flying.filter((flight) => flight.key !== key),
landed: new Set([...state.landed, key]),
});
},
fold() {
emit({ ...state, folding: true });
},
swap(data: Content) {
emit({ run: state.run + 1, key: data.key, data, t: 0, landed: new Set(), flying: [], folding: false });
},
};
}
const subscribeNever = () => () => {};
function usePageVisible() {
return React.useSyncExternalStore(
(onChange) => {
document.addEventListener("visibilitychange", onChange);
return () => document.removeEventListener("visibilitychange", onChange);
},
() => document.visibilityState === "visible",
() => true,
);
}
// A box relative to the root's padding box, in the root's own pixels even when an ancestor scales it.
function boxIn(el: Element, root: HTMLElement) {
const box = el.getBoundingClientRect();
const frame = root.getBoundingClientRect();
const scale = frame.width / (root.offsetWidth || frame.width) || 1;
return {
x: (box.left - frame.left) / scale - root.clientLeft,
y: (box.top - frame.top) / scale - root.clientTop,
w: box.width / scale,
h: box.height / scale,
};
}
const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--footnote-accent)";
/* ── Footnote ───────────────────────────────────────────── */
type Link = { run: number; kind: "claim"; index: number } | { run: number; kind: "source"; id: string };
type Preview = { run: number; id: string; anchor: { x: number; y: number; w: number; h: number }; width: number };
export function Footnote({
question,
answer,
sources,
read,
play = "view",
speed = TIMING.speed,
arc = NOTE.arc,
flightDuration = TIMING.fly.visualDuration,
flightBounce = TIMING.fly.bounce,
avatar,
status = defaultStatus,
onComplete,
className,
onKeyDown,
...props
}: FootnoteProps) {
const uid = React.useId();
// useReducedMotion is already true in the hydration render, so anything drawn from it waits for hydration.
const hydrated = React.useSyncExternalStore(subscribeNever, () => true, () => false);
const reduced = !!useReducedMotion() && hydrated;
const full = play === false || reduced;
const key = `${question}␞${answer
.map((claim) => `${claim.text}␟${[claim.cite ?? []].flat().join(",")}`)
.join("␝")}␞${sources.map((s) => s.id).join(",")}␞${read ?? ""}`;
const live: Content = { key, question, answer, sources, read };
const [store] = React.useState(() => createRun(live));
const state = React.useSyncExternalStore(store.subscribe, store.get, store.get);
const rootRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const seen = useInView(rootRef, { once: true, amount: 0.35 });
const inView = useInView(rootRef, { amount: 0.05 });
const visible = usePageVisible();
const playing = !full && (play === true ? hydrated : play === "view" && seen);
// A new question folds the old answer away only once something has played; otherwise it just swaps.
const foldable = playing;
const data = state.key === key || !foldable ? live : state.data;
// A slower flight holds the finish back by as much, so the check still waits for the last number.
const settle = TIMING.settle * (Math.max(0, flightDuration) / TIMING.fly.visualDuration);
const plan = buildPlan(data.answer, data.sources, data.read, speed, settle);
const run = state.run;
const t = state.t;
const latest = React.useRef({ live, plan, onComplete });
React.useLayoutEffect(() => {
latest.current = { live, plan, onComplete };
});
/* Swapping in a new answer. */
const heightFrom = React.useRef<number | null>(null);
React.useEffect(() => {
const now = store.get();
if (now.key === key || now.folding) return;
if (!foldable) {
store.swap(latest.current.live);
return;
}
store.fold();
heightFrom.current = rootRef.current?.offsetHeight ?? null;
const content = contentRef.current;
if (!content) return store.swap(latest.current.live);
// Driven as a plain number, so nothing is written to the element after the swap clears it. Not
// stopped on a re-run either: a newer question arriving mid-fold is picked up when it ends.
animate(0, 1, {
...TIMING.fold,
onUpdate: (p) => {
content.style.opacity = String(1 - p);
content.style.transform = `translateY(${-(NOTE.lift - 2) * p}px)`;
},
}).then(() => store.swap(latest.current.live));
}, [key, foldable, store]);
React.useLayoutEffect(() => {
const root = rootRef.current;
const content = contentRef.current;
if (content) {
content.style.opacity = "";
content.style.transform = "";
}
const from = heightFrom.current;
heightFrom.current = null;
if (from === null || !root) return;
const to = root.offsetHeight;
if (Math.abs(to - from) < 1) return;
const controls = animate(from, to, {
...TIMING.height,
onUpdate: (height) => {
root.style.height = `${height}px`;
},
});
controls.then(() => {
root.style.height = "";
});
return () => {
controls.stop();
root.style.height = "";
};
}, [run]);
/* The clock: jump from event to event while it's playing and in sight. */
const done = full || t >= plan.doneAt;
const running = playing && !done && !state.folding && inView && visible;
React.useEffect(() => {
if (!running) return;
const from = store.get();
const next = latest.current.plan.events.find((at) => at > from.t);
if (next === undefined) return;
const timer = window.setTimeout(() => {
const now = store.get();
const root = rootRef.current;
if (now.run !== from.run || !root) return;
const launch: Flight[] = [];
const landNow: string[] = [];
for (const flight of latest.current.plan.flights) {
if (flight.at <= from.t || flight.at > next) continue;
const markKey = `${now.run}:${flight.claim}:${flight.num}`;
const chip = root.querySelector(`[data-footnote-num="${CSS.escape(flight.id)}"]`);
const mark = root.querySelector(`[data-footnote-mark="${markKey}"]`);
if (!chip || !mark) {
landNow.push(markKey);
continue;
}
const ring = root.querySelector(`[data-footnote-ring="${CSS.escape(flight.id)}"]`);
if (ring) animate(ring, { opacity: [0.9, 0], transform: ["scale(1)", "scale(1.12)"] }, TIMING.pulse);
const a = boxIn(chip, root);
const b = boxIn(mark, root);
launch.push({ key: markKey, num: flight.num, from: { x: a.x, y: a.y }, to: { x: b.x, y: b.y } });
}
store.advance(next, launch, landNow);
}, next - from.t);
return () => window.clearTimeout(timer);
}, [running, t, run, store]);
// Once per answer, written and every number landed (or straight away when it doesn't play).
const completed = React.useRef(-1);
const finished = done && state.flying.length === 0 && !state.folding && state.key === key;
React.useEffect(() => {
if (!finished || completed.current === run) return;
if (!full && !playing) return;
completed.current = run;
latest.current.onComplete?.();
}, [finished, run, full, playing]);
/* What shows at this moment. */
const started = full || playing;
const readCount = full ? plan.reads : playing ? plan.readAt.filter((at) => at <= t).length : 0;
const readDone = readCount >= plan.reads;
const shownWords = full
? plan.words
: playing && t >= plan.writeAt
? clamp(Math.floor((t - plan.writeAt) / plan.per + 1e-6) + 1, 0, plan.words)
: 0;
const more = Math.max(0, readCount - plan.order.length);
const markShown = (claim: number, num: number) =>
full || (state.landed.has(`${run}:${claim}:${num}`) && !state.folding) || (done && state.flying.length === 0);
/* Links between claims and sources. */
const [link, setLink] = React.useState<Link | null>(null);
const [preview, setPreview] = React.useState<Preview | null>(null);
const linger = React.useRef(0);
const intent = React.useRef(0);
const leave = React.useRef(0);
const pointer = React.useRef<string>("mouse");
React.useEffect(
() => () => {
window.clearTimeout(linger.current);
window.clearTimeout(intent.current);
window.clearTimeout(leave.current);
},
[],
);
const activeLink = link && link.run === run ? link : null;
const activePreview = preview && preview.run === run ? preview : null;
const previewSource = activePreview ? data.sources.find((s) => s.id === activePreview.id) : undefined;
const holdLink = (next: Link) => {
window.clearTimeout(linger.current);
setLink(next);
};
const releaseLink = () => {
window.clearTimeout(linger.current);
linger.current = window.setTimeout(() => setLink(null), TIMING.linger);
};
const litClaims = new Set<number>();
const litSources = new Set<string>();
if (activeLink?.kind === "claim") {
litClaims.add(activeLink.index);
plan.claims[activeLink.index]?.marks.forEach((mark) => litSources.add(mark.id));
} else if (activeLink?.kind === "source") {
litSources.add(activeLink.id);
plan.claims.forEach((claim, i) => {
if (claim.marks.some((mark) => mark.id === activeLink.id)) litClaims.add(i);
});
}
const linking = activeLink !== null && (litClaims.size > 0 || litSources.size > 0);
const chipOf = (id: string) =>
rootRef.current?.querySelector<HTMLElement>(`[data-footnote-chip="${CSS.escape(id)}"]`) ?? null;
const openPreview = (id: string) => {
window.clearTimeout(intent.current);
window.clearTimeout(leave.current);
const root = rootRef.current;
const chip = chipOf(id);
if (!root || !chip) return;
const anchor = boxIn(chip, root);
setPreview({ run, id, anchor, width: Math.min(288, root.clientWidth - 16) });
};
const closePreview = (later: boolean) => {
window.clearTimeout(intent.current);
window.clearTimeout(leave.current);
if (later) leave.current = window.setTimeout(() => setPreview(null), TIMING.leave);
else setPreview(null);
};
const keepPreview = () => window.clearTimeout(leave.current);
// A tap outside clears a tapped link or source.
const tapped = activeLink !== null || activePreview !== null;
React.useEffect(() => {
if (!tapped) return;
const onDown = (event: PointerEvent) => {
if (event.pointerType === "mouse") return;
if (rootRef.current?.contains(event.target as Node)) return;
setLink(null);
setPreview(null);
};
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, [tapped]);
const statusText = status(readCount, readDone);
const finalStatus = status(plan.reads, true);
const reading = !done;
const showStatus = started && plan.reads > 0;
const enter = run > 0 && !full;
return (
<div
ref={rootRef}
role="article"
aria-labelledby={`${uid}-q`}
className={cn(
"@container/footnote relative w-full rounded-[20px] bg-white p-4 text-zinc-950 shadow-sm ring-1 ring-zinc-950/10 @[28rem]/footnote:p-5",
"[--footnote-accent:#5b54d6] dark:bg-zinc-900 dark:text-white dark:ring-white/10 dark:[--footnote-accent:#a49dff]",
className,
)}
onPointerDownCapture={(event) => {
pointer.current = event.pointerType;
}}
onKeyDown={(event) => {
onKeyDown?.(event);
if (event.key !== "Escape" || !activePreview) return;
event.preventDefault();
const chip = chipOf(activePreview.id);
closePreview(false);
if (chip && !chip.contains(document.activeElement)) chip.focus();
}}
{...props}
>
<div ref={contentRef}>
<div className="flex items-start gap-2.5">
<span
aria-hidden
className="mt-px grid size-6 shrink-0 place-items-center rounded-full bg-(--footnote-accent)/12 text-(--footnote-accent)"
>
{avatar ?? <SparkIcon className="size-3.5" />}
</span>
<motion.p
key={run}
id={`${uid}-q`}
initial={enter ? { opacity: 0, transform: `translateY(${NOTE.lift}px)` } : false}
animate={{ opacity: 1, transform: "translateY(0px)" }}
transition={TIMING.rise}
className="min-w-0 pt-0.5 text-[15px]/5 font-medium tracking-[-0.01em] text-balance"
>
{data.question}
</motion.p>
</div>
{plan.reads > 0 && (
<motion.div
key={`status-${run}`}
aria-hidden
initial={enter ? { opacity: 0, transform: `translateY(${NOTE.lift}px)` } : false}
animate={
showStatus
? { opacity: 1, transform: "translateY(0px)" }
: { opacity: 0, transform: `translateY(${NOTE.lift}px)` }
}
transition={full ? { duration: 0 } : TIMING.rise}
className="mt-2.5 flex h-4 items-center gap-1.5 pl-[34px] text-xs text-current/60"
>
{reading ? <SpinnerIcon className="size-3 motion-safe:animate-spin" /> : <CheckIcon className="size-3" />}
<span className="truncate tabular-nums">{statusText}</span>
</motion.div>
)}
<span className="sr-only" aria-live="polite">
{done && plan.reads > 0 ? finalStatus : ""}
</span>
<p
key={`answer-${run}`}
aria-busy={playing && !done}
className="relative mt-3 text-[14px]/[1.65] text-pretty @[28rem]/footnote:text-[15px]/[1.65]"
>
{!full && (
// Three lines held for the answer while the sources are read, gone as the first word lands.
<span
aria-hidden
className={cn(
`pointer-events-none absolute inset-0 overflow-hidden transition-opacity ${TIMING.skeleton}`,
shownWords > 0 && "opacity-0",
)}
>
{SKELETON.map((width, i) => (
<span
key={i}
className="absolute left-0 h-[0.62em] rounded-full bg-current/[0.06] motion-safe:animate-pulse"
style={{ top: `${i * 1.65 + 0.52}em`, width }}
/>
))}
</span>
)}
{plan.claims.map((claim, i) => {
const lit = litClaims.has(i);
const dim = linking && !lit;
// Hidden words aren't there to point at yet.
const written = claim.start + claim.words.length <= shownWords;
const toggleClaim = () => {
if (pointer.current === "mouse" || !written) return;
setLink(activeLink?.kind === "claim" && activeLink.index === i ? null : { run, kind: "claim", index: i });
};
return (
<React.Fragment key={i}>
{i > 0 && " "}
<span
className={cn(
`rounded-[4px] box-decoration-clone transition-[opacity,background-color,color] ${TIMING.link.ease}`,
lit || dim ? TIMING.link.on : TIMING.link.off,
lit ? "bg-(--footnote-accent)/12 text-current" : "text-current/80",
dim && "opacity-45",
)}
onPointerEnter={(event) => {
if (event.pointerType === "mouse" && written) holdLink({ run, kind: "claim", index: i });
}}
onPointerLeave={(event) => {
if (event.pointerType === "mouse") releaseLink();
}}
onClick={toggleClaim}
>
{claim.words.map((word, j) => {
const shown = claim.start + j < shownWords;
const text = (
<span
className={full ? undefined : `transition-opacity ${TIMING.wordFade}`}
style={shown ? undefined : { opacity: 0 }}
>
{word}
</span>
);
if (j < claim.words.length - 1) {
return (
<React.Fragment key={j}>
{text}{" "}
</React.Fragment>
);
}
// The last word and its markers never wrap apart.
return (
<span key={j} className="whitespace-nowrap">
{text}
{claim.marks.map((mark) => {
const source = data.sources.find((s) => s.id === mark.id)!;
const on = markShown(i, mark.num);
return (
<button
key={mark.num}
type="button"
data-footnote-mark={`${run}:${i}:${mark.num}`}
tabIndex={on ? 0 : -1}
aria-label={`Source ${mark.num}: ${source.site}`}
className={cn(
`relative -top-px ml-[3px] inline-flex h-4 min-w-4 cursor-pointer items-center justify-center rounded-full px-1 align-middle text-[10px]/none font-semibold tabular-nums transition-[background-color,color] ${TIMING.tint}`,
lit
? "bg-(--footnote-accent) text-white dark:text-zinc-950"
: "bg-(--footnote-accent)/12 text-(--footnote-accent) hover:bg-(--footnote-accent) hover:text-white dark:hover:text-zinc-950",
FOCUS,
)}
style={on ? undefined : { opacity: 0, pointerEvents: "none" }}
onFocus={() => holdLink({ run, kind: "claim", index: i })}
onBlur={releaseLink}
onClick={(event) => {
event.stopPropagation();
holdLink({ run, kind: "claim", index: i });
openPreview(mark.id);
chipOf(mark.id)?.focus({ preventScroll: true });
}}
>
{mark.num}
</button>
);
})}
</span>
);
})}
</span>
</React.Fragment>
);
})}
</p>
{plan.order.length > 0 && (
<div
className={cn(
`mt-4 border-t border-current/[0.08] pt-3 transition-[border-color] ${TIMING.rule}`,
readCount === 0 && "border-transparent",
)}
>
<ul aria-label="Sources" className="flex flex-wrap gap-1.5">
{plan.order.map(({ source, num }, i) => {
const dealt = i < readCount;
const lit = litSources.has(source.id);
const dim = linking && !lit;
const open = activePreview?.id === source.id;
const Tag = source.href ? "a" : "button";
return (
<li
key={source.id}
className={cn(
`flex min-w-0 transition-[opacity,translate,scale] ${TIMING.chip.ease}`,
lit || dim ? TIMING.chip.on : TIMING.chip.off,
!dealt ? "pointer-events-none scale-[0.96] opacity-0" : dim ? "opacity-50" : "opacity-100",
lit && "-translate-y-0.5",
)}
>
<Tag
{...(source.href
? { href: source.href, target: "_blank", rel: "noreferrer" }
: { type: "button" as const, "aria-expanded": open })}
data-footnote-chip={source.id}
tabIndex={dealt ? undefined : -1}
aria-describedby={open ? `${uid}-source` : undefined}
className={cn(
`relative flex h-7 min-w-0 max-w-[13rem] cursor-pointer items-center gap-1.5 rounded-full pr-2.5 pl-1.5 text-xs ring-1 transition-[background-color,box-shadow,color] ${TIMING.tint}`,
lit || open
? "bg-(--footnote-accent)/10 text-current ring-(--footnote-accent)/45"
: "bg-current/[0.04] text-current/75 ring-current/10 hover:bg-current/[0.07] hover:text-current",
FOCUS,
)}
onPointerEnter={(event: React.PointerEvent) => {
if (event.pointerType !== "mouse") return;
holdLink({ run, kind: "source", id: source.id });
window.clearTimeout(leave.current);
window.clearTimeout(intent.current);
if (!open) intent.current = window.setTimeout(() => openPreview(source.id), TIMING.intent);
}}
onPointerLeave={(event: React.PointerEvent) => {
if (event.pointerType !== "mouse") return;
releaseLink();
closePreview(true);
}}
onFocus={(event: React.FocusEvent<HTMLElement>) => {
holdLink({ run, kind: "source", id: source.id });
if (event.currentTarget.matches(":focus-visible")) openPreview(source.id);
}}
onBlur={() => {
releaseLink();
closePreview(true);
}}
onKeyDown={(event: React.KeyboardEvent) => {
if (event.key !== " " || !source.href) return;
event.preventDefault();
if (open) closePreview(false);
else openPreview(source.id);
}}
onClick={(event: React.MouseEvent) => {
const touch = pointer.current !== "mouse" && event.detail > 0;
if (!source.href || touch) {
event.preventDefault();
if (open) {
closePreview(false);
setLink(null);
} else {
openPreview(source.id);
if (touch) setLink({ run, kind: "source", id: source.id });
}
}
}}
>
<span
data-footnote-num={num === undefined ? undefined : source.id}
className={cn(
`grid h-4 min-w-4 shrink-0 place-items-center rounded-full px-1 text-[10px]/none font-semibold tabular-nums transition-[background-color,color] ${TIMING.tint}`,
lit
? "bg-(--footnote-accent) text-white dark:text-zinc-950"
: "bg-current/[0.08] text-current/70",
)}
aria-hidden
>
{num ?? monogram(source.site)}
</span>
<span className="truncate">{source.site}</span>
<span
data-footnote-ring={source.id}
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full opacity-0 ring-2 ring-(--footnote-accent)"
/>
</Tag>
</li>
);
})}
{plan.reads > plan.order.length && (
<li
className={cn(
`flex h-7 items-center rounded-full px-2.5 text-xs text-current/55 tabular-nums transition-[opacity,scale] ${TIMING.more}`,
more > 0 ? "opacity-100" : "scale-[0.96] opacity-0",
)}
>
+{full ? plan.reads - plan.order.length : more} more
<span className="sr-only"> sources</span>
</li>
)}
</ul>
</div>
)}
</div>
<div aria-hidden className="pointer-events-none absolute inset-0 z-10">
{state.flying.map((flight) => (
<FlyingNumber
key={flight.key}
flight={flight}
arc={arc}
duration={flightDuration}
bounce={flightBounce}
onLand={store.land}
/>
))}
</div>
<AnimatePresence>
{activePreview && previewSource && (
<SourceCard
key={`${activePreview.run}:${activePreview.id}`}
id={`${uid}-source`}
source={previewSource}
preview={activePreview}
reduced={reduced}
onPointerEnter={keepPreview}
onPointerLeave={() => closePreview(true)}
/>
)}
</AnimatePresence>
</div>
);
}
/* ── FlyingNumber ───────────────────────────────────────── */
// A copy of a chip's number on its way to the claim's marker. It leaves lit in the accent and
// lands looking exactly like the marker, which shows in the same frame this copy goes away. The
// marker is measured again each frame, so a reflow mid-flight (a resize, a late font) still lands.
function FlyingNumber({
flight,
arc,
duration,
bounce,
onLand,
}: {
flight: Flight;
arc: number;
duration: number;
bounce: number;
onLand: (key: string) => void;
}) {
const ref = React.useRef<HTMLSpanElement>(null);
const lit = React.useRef<HTMLSpanElement>(null);
React.useLayoutEffect(() => {
const el = ref.current;
const root = el?.parentElement?.parentElement;
if (!el || !root) return;
const mark = root.querySelector(`[data-footnote-mark="${flight.key}"]`);
const { from } = flight;
const place = (p: number) => {
const to = mark ? boxIn(mark, root) : flight.to;
const peak = { x: (from.x + to.x) / 2, y: Math.min(from.y, to.y) - arc };
const u = 1 - p;
const x = u * u * from.x + 2 * u * p * peak.x + p * p * to.x;
const y = u * u * from.y + 2 * u * p * peak.y + p * p * to.y;
const scale = 1 + NOTE.grow * Math.sin(Math.PI * clamp(p, 0, 1));
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
if (lit.current) lit.current.style.opacity = String(1 - smoothstep(0.45, 0.95, p));
};
place(0);
const controls = animate(0, 1, {
...TIMING.fly,
visualDuration: duration,
bounce,
onUpdate: place,
onComplete: () => onLand(flight.key),
});
return () => controls.stop();
}, [flight, onLand, arc, duration, bounce]);
return (
<span
ref={ref}
className="absolute top-0 left-0 grid h-4 min-w-4 origin-center place-items-center rounded-full bg-(--footnote-accent)/12 px-1 text-[10px]/none font-semibold text-(--footnote-accent) tabular-nums will-change-transform"
>
{flight.num}
<span
ref={lit}
className="absolute inset-0 grid place-items-center rounded-full bg-(--footnote-accent) text-white shadow-sm dark:text-zinc-950"
>
{flight.num}
</span>
</span>
);
}
/* ── SourceCard ─────────────────────────────────────────── */
// The source, grown out of its chip: above it when there's room in the card, below it when not.
function SourceCard({
id,
source,
preview,
reduced,
onPointerEnter,
onPointerLeave,
}: {
id: string;
source: FootnoteSource;
preview: Preview;
reduced: boolean;
onPointerEnter: () => void;
onPointerLeave: () => void;
}) {
const ref = React.useRef<HTMLDivElement>(null);
const { anchor, width } = preview;
React.useLayoutEffect(() => {
const el = ref.current;
const root = el?.offsetParent as HTMLElement | null;
if (!el || !root) return;
const left = clamp(anchor.x - 6, 8, Math.max(8, root.clientWidth - width - 8));
const height = el.offsetHeight;
const above = anchor.y - NOTE.gap - height >= 4;
el.style.left = `${left}px`;
el.style.top = `${above ? anchor.y - NOTE.gap - height : anchor.y + anchor.h + NOTE.gap}px`;
el.style.transformOrigin = `${anchor.x + Math.min(anchor.w, 28) / 2 - left}px ${above ? "100%" : "0%"}`;
}, [anchor, width]);
const passage = source.snippet ? markQuote(source.snippet, source.quote) : null;
return (
<motion.div
ref={ref}
id={id}
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1, transition: reduced ? TIMING.close : TIMING.open }}
exit={reduced ? { opacity: 0, transition: TIMING.close } : { opacity: 0, scale: 0.96, transition: TIMING.close }}
// Top, left and the origin are set before paint, once the card's height is known.
style={{ width }}
className="absolute z-20 rounded-2xl bg-white p-3 text-left text-zinc-950 shadow-lg ring-1 shadow-zinc-950/10 ring-zinc-950/10 dark:bg-zinc-800 dark:text-white dark:shadow-black/40 dark:ring-white/10"
onPointerEnter={(event) => {
if (event.pointerType === "mouse") onPointerEnter();
}}
onPointerLeave={(event) => {
if (event.pointerType === "mouse") onPointerLeave();
}}
>
<div className="flex items-center gap-1.5 text-[11px] text-zinc-500 dark:text-zinc-400">
<span
aria-hidden
className="grid size-4 shrink-0 place-items-center rounded-full bg-zinc-950/[0.06] text-[9px] font-semibold text-zinc-600 dark:bg-white/10 dark:text-zinc-300"
>
{monogram(source.site)}
</span>
<span className="truncate">{source.site}</span>
</div>
<p className="mt-1.5 text-[13px]/5 font-medium tracking-[-0.005em]">
{source.href ? (
<a
href={source.href}
target="_blank"
rel="noreferrer"
tabIndex={-1}
className="underline-offset-2 hover:underline"
>
{source.title}
<ArrowIcon className="ml-1 inline size-3 -translate-y-px text-zinc-400" />
</a>
) : (
source.title
)}
</p>
{passage && (
<p className="mt-1.5 line-clamp-4 text-xs/[1.55] text-zinc-600 dark:text-zinc-300">
“{passage.before}
{passage.quote && (
<mark className="rounded-[3px] bg-(--footnote-accent)/15 px-0.5 text-zinc-950 box-decoration-clone dark:text-white">
{passage.quote}
</mark>
)}
{passage.after}”
</p>
)}
</motion.div>
);
}
function markQuote(snippet: string, quote?: string) {
const at = quote ? snippet.toLowerCase().indexOf(quote.toLowerCase()) : -1;
if (!quote || at < 0) return { before: snippet, quote: "", after: "" };
return { before: snippet.slice(0, at), quote: snippet.slice(at, at + quote.length), after: snippet.slice(at + quote.length) };
}
/* ── Icons ──────────────────────────────────────────────── */
function SparkIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" className={className} aria-hidden>
<path
d="M8 1.5c.3 2.9 1.1 4.6 2.2 5.4 1 .8 2.4 1.1 4.3 1.1-1.9 0-3.3.3-4.3 1.1C9.1 9.9 8.3 11.6 8 14.5c-.3-2.9-1.1-4.6-2.2-5.4C4.8 8.3 3.4 8 1.5 8c1.9 0 3.3-.3 4.3-1.1C6.9 6.1 7.7 4.4 8 1.5Z"
fill="currentColor"
/>
</svg>
);
}
function SpinnerIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 12 12" className={className} fill="none" aria-hidden>
<circle cx="6" cy="6" r="4.75" stroke="currentColor" strokeOpacity={0.25} strokeWidth={1.5} />
<path d="M6 1.25A4.75 4.75 0 0 1 10.75 6" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" />
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 12 12" className={className} fill="none" aria-hidden>
<circle cx="6" cy="6" r="5.25" fill="currentColor" fillOpacity={0.14} />
<path d="m3.8 6.1 1.5 1.5 2.9-3.1" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function ArrowIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 12 12" className={className} fill="none" aria-hidden>
<path d="M4 8l4-4M4.5 4H8v3.5" stroke="currentColor" strokeWidth={1.3} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}