Margin Note
Annotated Line Chart for React
A line chart that reads itself. A reader sweeps across the series and inks it as it goes; wherever something happened it stops, marks the point and writes a note in the margin, level with the point and tied to it by a leader. Hover a note and its stretch of the chart lights up.
- Chart
- Data
- AI
Uses motion, clsx, tailwind-merge
<MarginNote />replays the demo when you let go.
Kiln · Weekly signups
15,859last 16 weeks
Signups are up 38% since the free plan went to three seats.
16 points, Jul 7 to Oct 20, from 498 to 1,720. Note 1, at Jul 28: Launch week (+163%). 1,720 signups, 2.6× a normal week. Note 2, at Sep 1: Login outage (−42%). Sign-in was down for six hours on Tuesday. Note 3, at Oct 13: Free plan, three seats (+38%). Signups held 38% higher for four weeks.
"use client";
import * as React from "react";
import { MarginNote, type MarginNoteNote } from "@/components/ui/margin-note";
const WEEKS = [
"Jul 7", "Jul 14", "Jul 21", "Jul 28", "Aug 4", "Aug 11", "Aug 18", "Aug 25",
"Sep 1", "Sep 8", "Sep 15", "Sep 22", "Sep 29", "Oct 6", "Oct 13", "Oct 20",
];
const SIGNUPS = [612, 640, 655, 1720, 1180, 905, 880, 862, 498, 870, 892, 1150, 1205, 1238, 1262, 1290];
const NOTES: MarginNoteNote[] = [
{ at: 3, title: "Launch week", body: "1,720 signups, 2.6× a normal week.", delta: "+163%" },
{ at: 8, title: "Login outage", body: "Sign-in was down for six hours on Tuesday.", delta: "−42%" },
{ at: 11, to: 14, title: "Free plan, three seats", body: "Signups held 38% higher for four weeks.", delta: "+38%" },
];
export function MarginNoteDemo() {
const [run, setRun] = React.useState(0);
return (
<div className="w-full max-w-3xl rounded-2xl bg-white p-5 text-zinc-900 shadow-sm ring-1 ring-zinc-950/[0.08] sm:p-7 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-white/10">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">Kiln · Weekly signups</p>
<p className="mt-1 flex items-baseline gap-2">
<span className="text-2xl font-semibold tracking-tight tabular-nums">15,859</span>
<span className="text-xs text-zinc-500 dark:text-zinc-400">last 16 weeks</span>
</p>
</div>
<button
type="button"
onClick={() => setRun((r) => r + 1)}
className="inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-xs font-medium text-zinc-600 ring-1 ring-zinc-950/10 transition-colors hover:bg-zinc-50 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:text-zinc-300 dark:ring-white/15 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-white"
>
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={1.6} aria-hidden>
<path d="M2.75 8a5.25 5.25 0 1 0 1.6-3.77M2.75 2.75v2.5h2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Read again
</button>
</div>
<MarginNote
key={run}
label="Kiln weekly signups"
data={SIGNUPS}
labels={WEEKS}
notes={NOTES}
summary="Signups are up 38% since the free plan went to three seats."
format={(value) => value.toLocaleString("en-US")}
/>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content.
Margin Note: A line chart that reads itself. A reader sweeps across the series and inks it as it goes; wherever something happened it stops, marks the point and writes a note in the margin, level with the point and tied to it by a leader. Hover a note and its stretch of the chart lights up.
This demo, Weekly Signups: Sixteen weeks of signups for a design tool, read with notes on launch week, a login outage and the free plan going to three seats.
1. Install it:
npx shadcn@latest add @keyframeui/margin-note-demo
(If the CLI can't find @keyframeui, use https://keyframeui.com/r/margin-note-demo.json instead.)
It adds the demo, components/margin-note-demo.tsx, and the component, components/ui/margin-note.tsx.
2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <MarginNote> on its own.
3. These props set its feel. Change them before you change the file:
- duration (default 2400): How long the read takes, in ms, pauses at the notes included.
- hold (default 280): How long the reader stops at each noted point, in ms, as if writing. The pauses never take more than 45% of `duration`.
4. Don't change, in components/ui/margin-note.tsx:
- The read is one motion `animate` whose `onUpdate` writes the clip, the glow and the reader's transforms straight to the DOM; React re-renders only when the reader passes a noted point.
- The reader holds at each noted point, the holds never take more than `holdShare` of `duration`, and between them it sweeps at one steady, linear pace.
- Every note is rendered from the start, so the margin holds its room, and each is placed level with its point once measured; nothing shifts when the notes arrive.
- Inputs are keyed by content (`dataKey`, `notesKey`), so a re-render with equal inline arrays doesn't restart the read; new data or notes read again from the start.
- Under reduced motion or `play={false}` nothing sweeps: the chart arrives read with every note in place, and highlights and the crosshair still work.
Its timing is all in the TIMING block at the top of that file.
Docs: https://keyframeui.com/components/margin-note.mdInstallation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/margin-note.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/margin-noteInstall the dependencies
npm i motion clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/margin-note.tsx"use client"; import * as React from "react"; import { animate, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; /** * Keep: * - The read is one motion `animate` whose `onUpdate` writes the clip, the glow and the reader's * transforms straight to the DOM; React re-renders only when the reader passes a noted point. * - The reader holds at each noted point, the holds never take more than `holdShare` of `duration`, * and between them it sweeps at one steady, linear pace. * - Every note is rendered from the start, so the margin holds its room, and each is placed level * with its point once measured; nothing shifts when the notes arrive. * - Inputs are keyed by content (`dataKey`, `notesKey`), so a re-render with equal inline arrays * doesn't restart the read; new data or notes read again from the start. * - Under reduced motion or `play={false}` nothing sweeps: the chart arrives read with every note * in place, and highlights and the crosshair still work. */ const TIMING = { /** The whole read, pauses included, in ms (the `duration` prop). */ duration: 2400, /** The reader stopping at each noted point, in ms (the `hold` prop). */ hold: 280, /** The shortest a read can take, in ms; a shorter `duration` is raised to it. */ shortest: 400, /** The reader's pace between holds. */ sweepEase: "linear", /** A noted point popping in. */ pop: { type: "spring", visualDuration: 0.3, bounce: 0.25 }, /** Its ring spreading out and fading, in seconds. */ ring: { duration: 0.6, ease: [0.22, 1, 0.36, 1] }, /** The leader drawing out to the margin, in seconds. */ leader: { duration: 0.32, ease: [0.22, 1, 0.36, 1], delay: 0.04 }, /** The note sliding in behind it, after a delay in seconds. */ note: { type: "spring", visualDuration: 0.3, bounce: 0, delay: 0.1 }, /** Between summary words, in seconds. */ word: 0.035, /** The longest the summary's stagger runs, in seconds. */ wordSpread: 0.3, /** A summary word rising. */ rise: { type: "spring", visualDuration: 0.3, bounce: 0 }, /** The reader fading once the read ends, in seconds. */ reader: { duration: 0.25, ease: "easeOut" }, /** Highlights and dimming, in seconds. */ light: { duration: 0.15, ease: "easeOut" }, /** The same highlights where CSS runs them: a point's halo, a leader's stroke, a note dimming. */ lightClass: "duration-150", } as const; /* ───────────────────────────────────────────────────────── * MARGIN NOTE STORYBOARD * * rest the chart's frame: four hairlines with their values, * a few dates along the bottom and the series in a * faint print, not read yet. The margin beside it is * empty but already holds its room, so nothing moves * when the notes arrive * view 35% in view, a reader (a hairline, a dot riding the * series and a soft glow behind it) sweeps left to * right at a steady pace, inking the line and its fill * as it passes. 2.4s for the whole read, pauses * included * note at a noted point the reader stops for 280ms, as if * writing. The point pops (scale 0.6 → 1 with a ring * spreading out and fading), the leader draws from it * to the margin in 0.32s and the note slides 8px in * behind it. Then the reader moves on. A note about a * stretch shades its band as the reader crosses it and * is written at the stretch's end * margin each note sits level with its point, tied to it by a * dotted leader, so the eye runs straight across. * Crowded notes step out of each other's way and their * leaders bend to reach them * summary the read ends, the reader fades (0.25s) and the * takeaway above the chart rises in word by word, 35ms * apart * link hover or focus a note: its point rings, its band * shades, its leader firms up and the other notes fade * to half. Hover the chart: a crosshair snaps to the * nearest point with its label and value, and lights * the note there if it has one * again new data or notes read again from the start * narrow under 34rem the margin drops under the chart as a * numbered list, the points carry the numbers and the * leaders go * touch tap a note to light it, tap it again to clear. Drag * across the chart to move the crosshair * keys the chart takes focus: ← → step the crosshair point * by point, Home / End jump to the ends, Esc clears. * Notes are buttons in reading order. The value under * the crosshair is announced * reduced no sweep: the chart arrives read, every note in * place. Highlights and the crosshair work, unanimated * ───────────────────────────────────────────────────────── */ const MARGIN = { holdShare: 0.45, // the pauses never take more than this share of the read view: 0.35, // share of the chart in view before it reads bend: 14, // px a leader runs before it turns toward a note that had to step aside slide: 8, // px a note slides in from lift: 6, // px a summary word rises from dim: 0.45, // opacity of the other notes while one is lit glow: 56, // px of glow trailing the reader gap: 10, // px between notes stacked in the margin hang: 28, // px a note may hang below the plot, beside the labels, before the stack lifts badge: 9, // px from a note's top to its number's middle, level with the point } as const; /* ── Public types ───────────────────────────────────────── */ export interface MarginNoteNote { /** Index in `data` of the point the note is about. */ at: number; /** Index where a note about a stretch of time ends. The band between `at` and `to` shades. */ to?: number; /** The note's first line. Keep it short. */ title: string; /** One line of detail under the title. */ body?: string; /** A small figure beside the title, like "+38%". */ delta?: string; } export interface MarginNoteProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> { /** The series, one value per point, oldest first. */ data: number[]; /** A label per point, like a date. A few run under the chart, and the crosshair shows the one it's on. */ labels?: string[]; /** What the reader writes in the margin. Each note points at an index in `data`; they're numbered in reading order. */ notes?: MarginNoteNote[]; /** The takeaway above the chart, written once the read ends. */ summary?: React.ReactNode; /** Formats values on the axis and under the crosshair. Defaults to compact numbers (1.2K). */ format?: (value: number) => string; /** A second series to compare against, like last period. Drawn dashed and never read. */ compare?: number[]; /** `"view"` reads once the chart is 35% in view. `true` reads now, and again each time it flips back to `true`. `false` shows it already read. */ play?: boolean | "view"; /** How long the read takes, in ms, pauses at the notes included. */ duration?: number; /** How long the reader stops at each noted point, in ms, as if writing. The pauses never take more than 45% of `duration`. */ hold?: number; /** What the chart shows, like "Weekly signups". Screen readers hear it first. */ label?: string; /** Called when a read ends. */ onComplete?: () => void; } /* ── Geometry ───────────────────────────────────────────── */ // Everything is worked out in a unit square (x right, y down) and scaled to the measured box at // draw time, so a resize never needs the data again and strokes stay a true 1.5px. type Point = { x: number; y: number }; type Segment = { x0: number; y0: number; c1: number; c2: number; x1: number; y1: number }; type Series = { points: Point[]; segments: Segment[] }; type Geometry = { min: number; max: number; ticks: number[]; zero: number | null; main: Series; compare: Series | null }; const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); const round = (v: number) => Math.round(v * 100) / 100; const compact = new Intl.NumberFormat("en", { notation: "compact", maximumFractionDigits: 1 }); const formatCompact = (value: number) => compact.format(value); const noopSubscribe = () => () => {}; const NO_NOTES: MarginNoteNote[] = []; /** Round numbers for the hairlines, a step of 1, 2, 2.5 or 5 × 10ⁿ, with a little air above the peak. */ function niceScale(values: number[]) { let lo = Math.min(...values); let hi = Math.max(...values); if (!Number.isFinite(lo)) return { min: 0, max: 1, ticks: [0, 1] }; if (lo === hi) { const pad = Math.abs(lo) * 0.1 || 1; lo -= pad; hi += pad; } const span = hi - lo; hi += span * 0.08; lo = lo >= 0 ? Math.max(0, lo - span * 0.04) : lo - span * 0.04; const raw = (hi - lo) / 4; const mag = 10 ** Math.floor(Math.log10(raw)); const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw) ?? raw; const min = Math.floor(lo / step) * step; const max = Math.ceil(hi / step) * step; const ticks: number[] = []; for (let v = min; v <= max + step / 2; v += step) ticks.push(Number(v.toPrecision(12))); return { min, max, ticks }; } /** A monotone cubic through the points (Fritsch–Carlson), so the curve never overshoots a value. */ function monotone(points: Point[]): Segment[] { const n = points.length; if (n < 2) return []; const d: number[] = []; for (let i = 0; i < n - 1; i++) d.push((points[i + 1].y - points[i].y) / (points[i + 1].x - points[i].x)); const m: number[] = new Array(n); m[0] = d[0]; m[n - 1] = d[n - 2]; for (let i = 1; i < n - 1; i++) m[i] = d[i - 1] * d[i] <= 0 ? 0 : (d[i - 1] + d[i]) / 2; for (let i = 0; i < n - 1; i++) { if (d[i] === 0) { m[i] = 0; m[i + 1] = 0; continue; } const a = m[i] / d[i]; const b = m[i + 1] / d[i]; const s = a * a + b * b; if (s > 9) { const t = 3 / Math.sqrt(s); m[i] = t * a * d[i]; m[i + 1] = t * b * d[i]; } } return points.slice(0, -1).map((p, i) => { const q = points[i + 1]; const dx = q.x - p.x; return { x0: p.x, y0: p.y, c1: p.y + (m[i] * dx) / 3, c2: q.y - (m[i + 1] * dx) / 3, x1: q.x, y1: q.y }; }); } function series(values: number[], min: number, max: number): Series { const n = values.length; const points = values.map((v, i) => ({ x: n === 1 ? 0.5 : i / (n - 1), y: (max - v) / (max - min) })); return { points, segments: monotone(points) }; } function buildGeometry(data: number[], compare: number[] | null): Geometry { const { min, max, ticks } = niceScale(compare ? [...data, ...compare] : data); return { min, max, ticks, zero: min < 0 && max > 0 ? max / (max - min) : null, main: series(data, min, max), compare: compare && compare.length ? series(compare, min, max) : null, }; } /** The series' height at a point across, exact on the drawn curve: its x runs linearly in each segment. */ function heightAt({ points, segments }: Series, fx: number) { if (!segments.length) return points[0]?.y ?? 0.5; const i = clamp(Math.floor(fx * segments.length), 0, segments.length - 1); const s = segments[i]; const t = clamp((fx - s.x0) / (s.x1 - s.x0), 0, 1); const u = 1 - t; return u * u * u * s.y0 + 3 * u * u * t * s.c1 + 3 * u * t * t * s.c2 + t * t * t * s.y1; } function linePath({ points, segments }: Series, w: number, h: number) { if (!points.length) return ""; if (!segments.length) return `M${round(points[0].x * w - 0.5)},${round(points[0].y * h)}h1`; let d = `M${round(segments[0].x0 * w)},${round(segments[0].y0 * h)}`; for (const s of segments) { const third = (s.x1 - s.x0) / 3; d += `C${round((s.x0 + third) * w)},${round(s.c1 * h)},${round((s.x1 - third) * w)},${round(s.c2 * h)},${round(s.x1 * w)},${round(s.y1 * h)}`; } return d; } function areaPath(s: Series, base: number, w: number, h: number) { if (s.segments.length === 0) return ""; const first = s.points[0]; const last = s.points[s.points.length - 1]; return `${linePath(s, w, h)}L${round(last.x * w)},${round(base * h)}L${round(first.x * w)},${round(base * h)}Z`; } /** Which labels fit along the bottom: evenly stepped from the first, plus the last when it has room. */ function labelIndices(labels: string[] | undefined, n: number, width: number) { if (!labels?.length || n === 0) return []; if (n === 1) return [0]; const longest = Math.max(...labels.map((l) => l.length)); const room = longest * 6.4 + 20; // px one label needs at 11px, with air const fit = clamp(Math.floor(width / room), 2, 8); const step = Math.max(1, Math.ceil((n - 1) / (fit - 1))); const picked: number[] = []; for (let i = 0; i < n; i += step) picked.push(i); const last = n - 1; if (picked[picked.length - 1] !== last) { // The last label always shows; it takes the place of a neighbor that would crowd it. if (last - picked[picked.length - 1] < step * 0.6) picked.pop(); picked.push(last); } return picked; } /* ── Notes in reading order ─────────────────────────────── */ type Note = MarginNoteNote & { index: number; // position in reading order, from 0 anchor: number; // the point the note is written at: the end of its stretch, or its point from: number; // the stretch's start (equals anchor for a point) }; function readingOrder(notes: MarginNoteNote[], n: number): Note[] { if (n === 0) return []; return notes .map((note) => { const a = clamp(Math.round(note.at), 0, n - 1); const b = note.to === undefined ? a : clamp(Math.round(note.to), 0, n - 1); return { ...note, from: Math.min(a, b), anchor: Math.max(a, b) }; }) .sort((p, q) => p.anchor - q.anchor || p.from - q.from) .map((note, index) => ({ ...note, index })); } /* ── Layout, measured ───────────────────────────────────── */ type Layout = { w: number; // the plot's size in px h: number; plotX: number; // the plot's offset inside the body plotY: number; wide: boolean; // the margin beside the chart (34rem and up), not under it column: { x: number; y: number; h: number } | null; // the margin column, when its notes are placed by hand tops: number[]; // each note's top inside the column, in reading order }; const sameLayout = (a: Layout | null, b: Layout) => !!a && a.w === b.w && a.h === b.h && a.plotX === b.plotX && a.plotY === b.plotY && a.wide === b.wide && a.column?.x === b.column?.x && a.column?.y === b.column?.y && a.column?.h === b.column?.h && a.tops.length === b.tops.length && a.tops.every((t, i) => t === b.tops[i]); /** Puts each note level with its point, then steps crowded ones down (and back up from the floor). */ function placeNotes(want: number[], heights: number[], floor: number, gap: number) { const order = want.map((_, i) => i).sort((a, b) => want[a] - want[b]); const tops = new Array<number>(want.length); let cursor = -Infinity; for (const i of order) { tops[i] = Math.max(want[i], cursor, 0); cursor = tops[i] + heights[i] + gap; } // Pushed past the floor? Lift the stack back up, as far as the top allows. let limit = floor; for (let k = order.length - 1; k >= 0; k--) { const i = order[k]; if (tops[i] + heights[i] > limit) tops[i] = Math.max(0, limit - heights[i]); limit = tops[i] - gap; } return tops.map(Math.round); } /* ── MarginNote ─────────────────────────────────────────── */ type Run = { token: number; phase: "idle" | "reading" | "done"; shown: number }; const startRun = (r: Run): Run => ({ token: r.token + 1, phase: "reading", shown: 0 }); export function MarginNote({ data, labels, notes = NO_NOTES, summary, format = formatCompact, compare, play = "view", duration = TIMING.duration, hold: holdProp = TIMING.hold, label = "Chart", onComplete, className, ...props }: MarginNoteProps) { const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, ""); const hydrated = React.useSyncExternalStore(noopSubscribe, () => true, () => false); const reduced = (useReducedMotion() ?? false) && hydrated; // Content keys, so inline arrays don't restart the read on every render. const dataKey = data.join(","); const compareKey = compare?.join(",") ?? ""; const notesKey = JSON.stringify(notes); const geo = React.useMemo( () => buildGeometry( dataKey ? dataKey.split(",").map(Number) : [], compareKey ? compareKey.split(",").map(Number) : null, ), [dataKey, compareKey], ); const n = geo.main.points.length; const order = React.useMemo(() => readingOrder(JSON.parse(notesKey) as MarginNoteNote[], n), [notesKey, n]); /* the run: idle → reading → done */ const [run, setRun] = React.useState<Run>({ token: 0, phase: "idle", shown: 0 }); const contentKey = `${dataKey}|${notesKey}`; const [seenKey, setSeenKey] = React.useState(contentKey); const [seenPlay, setSeenPlay] = React.useState<MarginNoteProps["play"] | null>(null); if (seenKey !== contentKey) { // New data reads again from the start (in view: right away, see the observer below). setSeenKey(contentKey); setRun((r) => (play === true ? startRun(r) : { token: r.token + 1, phase: "idle", shown: 0 })); } if (seenPlay !== play) { setSeenPlay(play); if (play === true) setRun(startRun); else if (play === "view" && seenPlay !== null) setRun((r) => ({ token: r.token + 1, phase: "idle", shown: 0 })); } const still = play === false || reduced; // never sweeps: arrives read const phase = play === false ? "done" : run.phase; const done = phase === "done" || still; const shown = done ? order.length : run.shown; const rootRef = React.useRef<HTMLDivElement>(null); const bodyRef = React.useRef<HTMLDivElement>(null); const plotRef = React.useRef<HTMLDivElement>(null); const columnRef = React.useRef<HTMLOListElement>(null); const clipRef = React.useRef<SVGRectElement>(null); const glowRef = React.useRef<SVGRectElement>(null); const readerRef = React.useRef<HTMLDivElement>(null); const readerDotRef = React.useRef<HTMLDivElement>(null); const layoutRef = React.useRef<Layout | null>(null); const callbacks = React.useRef({ onComplete }); React.useEffect(() => { callbacks.current = { onComplete }; }); /* measure: the plot in px, and where each note sits in the margin */ const [layout, setLayout] = React.useState<Layout | null>(null); React.useEffect(() => { layoutRef.current = layout; }, [layout]); const anchorsKey = order.map((note) => note.anchor).join(","); React.useEffect(() => { const root = rootRef.current; const body = bodyRef.current; const plot = plotRef.current; if (!root || !body || !plot) return; const anchors = anchorsKey ? anchorsKey.split(",").map(Number) : []; const measure = () => { const b = body.getBoundingClientRect(); const p = plot.getBoundingClientRect(); const column = columnRef.current; const c = column?.getBoundingClientRect(); const wide = !!c && c.left >= p.right - 1; // the margin sits beside the plot, not under it const next: Layout = { w: Math.round(p.width), h: Math.round(p.height), plotX: Math.round(p.left - b.left), plotY: Math.round(p.top - b.top), wide, column: null, tops: [], }; if (wide && column && c && anchors.length) { const items = Array.from(column.children) as HTMLElement[]; const heights = items.map((item) => item.offsetHeight); const want = anchors.map( (a) => next.plotY - (c.top - b.top) + geo.main.points[a].y * next.h - MARGIN.badge, ); const floor = Math.max(next.h + MARGIN.hang, heights.reduce((sum, h) => sum + h + MARGIN.gap, 0)); const tops = placeNotes(want, heights, floor, MARGIN.gap); const bottom = Math.max(...tops.map((t, i) => t + heights[i])); next.column = { x: Math.round(c.left - b.left), y: Math.round(c.top - b.top), h: Math.max(next.h, bottom) }; next.tops = tops; } setLayout((prev) => (sameLayout(prev, next) ? prev : next)); }; const observer = new ResizeObserver(measure); observer.observe(root); observer.observe(plot); if (columnRef.current) { observer.observe(columnRef.current); for (const item of Array.from(columnRef.current.children)) observer.observe(item); } let alive = true; document.fonts?.ready.then(() => alive && measure()); return () => { alive = false; observer.disconnect(); }; }, [anchorsKey, geo]); /* play once 35% in view */ const auto = play === "view"; React.useEffect(() => { const root = rootRef.current; if (!auto || !root || run.phase !== "idle") return; const observer = new IntersectionObserver( ([entry]) => { if (!entry || entry.intersectionRatio < MARGIN.view - 0.01) return; observer.disconnect(); setRun((r) => (r.phase === "idle" ? startRun(r) : r)); }, { threshold: MARGIN.view }, ); observer.observe(root); return () => observer.disconnect(); }, [auto, run.phase, run.token]); /* the read */ const stopsKey = order.map((note) => note.anchor).join(","); React.useEffect(() => { if (run.phase !== "reading" || play === false) return; const token = run.token; const finish = () => { setRun((r) => (r.token === token && r.phase === "reading" ? { ...r, phase: "done", shown: Infinity } : r)); callbacks.current.onComplete?.(); }; if (still) { const id = window.setTimeout(finish, 0); return () => window.clearTimeout(id); } // The reader's path through time: steady sweeps, with a hold at each noted point. const count = Math.max(1, n - 1); const marks = stopsKey ? [...new Set(stopsKey.split(",").map(Number))].map((a) => a / count) : []; const total = Math.max(duration, TIMING.shortest); const hold = Math.min(Math.max(0, holdProp), (total * MARGIN.holdShare) / Math.max(1, marks.length)); const sweep = total - hold * marks.length; const xs = [0]; const ts = [0]; let t = 0; let at = 0; for (const mark of marks) { t += (mark - at) * sweep; xs.push(mark, mark); ts.push(t, t + hold); t += hold; at = mark; } xs.push(1); ts.push(t + (1 - at) * sweep); const end = ts[ts.length - 1] || 1; const position = (u: number) => { const time = u * end; for (let i = 1; i < ts.length; i++) { if (time <= ts[i]) { const span = ts[i] - ts[i - 1]; return span <= 0 ? xs[i] : xs[i - 1] + ((time - ts[i - 1]) / span) * (xs[i] - xs[i - 1]); } } return 1; }; const anchors = stopsKey ? stopsKey.split(",").map((a) => Number(a) / count) : []; let revealed = 0; const paint = (fx: number) => { const lay = layoutRef.current; if (!lay) return; const x = fx * lay.w; clipRef.current?.setAttribute("width", String(round(x + 1))); glowRef.current?.setAttribute("x", String(round(x - MARGIN.glow))); if (readerRef.current) readerRef.current.style.transform = `translate3d(${round(x)}px,0,0)`; if (readerDotRef.current) { const y = heightAt(geo.main, fx) * lay.h; readerDotRef.current.style.transform = `translate3d(${round(x)}px,${round(y)}px,0)`; } let passed = 0; while (passed < anchors.length && anchors[passed] <= fx + 1e-6) passed++; if (passed > revealed) { revealed = passed; setRun((r) => (r.token === token ? { ...r, shown: passed } : r)); } }; const controls = animate(0, 1, { duration: end / 1000, ease: TIMING.sweepEase, onUpdate: (u) => paint(position(u)), onComplete: () => { paint(1); finish(); }, }); return () => controls.stop(); }, [run.phase, run.token, play, still, n, stopsKey, duration, holdProp, geo]); /* lighting a note: hover, focus or a tap on it, or the crosshair landing on its point */ const [pinned, setPinned] = React.useState<number | null>(null); const [cross, setCross] = React.useState<number | null>(null); const [spoken, setSpoken] = React.useState(""); // the value the keyboard last stepped to, read out const reading = run.phase === "reading" && !still; const crossNote = cross === null ? -1 : order.findIndex((note) => cross >= note.from && cross <= note.anchor); const lit = pinned ?? (crossNote >= 0 && crossNote < shown ? crossNote : null); const litNote = lit === null ? null : order[lit]; const valueAt = (i: number) => data[i]; const describe = (i: number) => `${labels?.[i] ?? `Point ${i + 1}`}: ${format(valueAt(i))}${compare?.[i] !== undefined ? `, compared with ${format(compare[i])}` : ""}`; const indexAt = (clientX: number) => { const rect = plotRef.current?.getBoundingClientRect(); if (!rect || n === 0) return null; return Math.round(clamp((clientX - rect.left) / rect.width, 0, 1) * (n - 1)); }; const follow = (event: React.PointerEvent) => { if (reading) return; if (event.pointerType !== "mouse" && event.buttons === 0) return; const i = indexAt(event.clientX); if (i !== cross) setCross(i); }; const onKeyDown = (event: React.KeyboardEvent) => { if (n === 0) return; const last = n - 1; const from = cross ?? last; let next: number | null = from; if (event.key === "ArrowRight") next = cross === null ? last : Math.min(last, from + 1); else if (event.key === "ArrowLeft") next = cross === null ? last : Math.max(0, from - 1); else if (event.key === "Home") next = 0; else if (event.key === "End") next = last; else if (event.key === "Escape") next = null; else return; event.preventDefault(); setCross(next); setSpoken(next === null ? "" : describe(next)); if (event.key === "Escape") setPinned(null); }; /* drawing sizes: measured px, or a stretched unit box until the first measure */ const w = layout?.w ?? 1000; const h = layout?.h ?? 1000; const measured = layout !== null; const base = geo.zero ?? 1; const line = linePath(geo.main, w, h); const area = areaPath(geo.main, base, w, h); const ghost = geo.compare ? linePath(geo.compare, w, h) : ""; const xLabels = labelIndices(labels, n, layout?.w ?? 600); const gutter = `calc(${Math.max(...geo.ticks.map((tick) => format(tick).length))}ch + 0.75rem)`; const clipId = `${uid}-read`; const fillId = `${uid}-fill`; const glowId = `${uid}-glow`; const descId = `${uid}-desc`; const placed = layout?.wide && layout.column && layout.tops.length === order.length ? layout : null; return ( <div ref={rootRef} className={cn( "@container/margin-note relative w-full [--margin-note-accent:#5b54d6] dark:[--margin-note-accent:#a5a0ff]", className, )} {...props} > {summary !== undefined && <Summary summary={summary} shown={done} instant={still} />} <div ref={bodyRef} className={cn( "relative grid grid-cols-1 gap-x-7 gap-y-5", order.length > 0 && "@[34rem]/margin-note:grid-cols-[minmax(0,1fr)_minmax(10rem,13.5rem)]", )} > <div className="min-w-0 pt-2"> <div className="flex"> {/* the values, in a gutter so the series never runs through them */} <div aria-hidden className="relative shrink-0 text-[11px] leading-4 text-current/55 tabular-nums" style={{ width: gutter }} > {geo.ticks.map((tick) => ( <span key={tick} className="absolute right-2.5 -translate-y-1/2 whitespace-nowrap" style={{ top: `${((geo.max - tick) / (geo.max - geo.min)) * 100}%` }} > {format(tick)} </span> ))} </div> <div ref={plotRef} role="group" aria-roledescription="chart" tabIndex={0} aria-label={`${label}. Arrow keys read the values.`} aria-describedby={descId} onPointerMove={follow} onPointerDown={(event) => { if (event.pointerType === "mouse") return; (event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId); const i = indexAt(event.clientX); if (!reading) setCross(i); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") setCross(null); }} onKeyDown={onKeyDown} onBlur={() => setCross(null)} className="relative h-[var(--margin-note-height,12.5rem)] min-w-0 flex-1 touch-pan-y rounded-[2px] outline-offset-4 outline-[color:var(--margin-note-accent)] select-none focus-visible:outline-2 @[34rem]/margin-note:h-[var(--margin-note-height,15rem)]" > {/* hairlines */} <div aria-hidden className="pointer-events-none absolute inset-0"> {geo.ticks.map((tick) => ( <div key={tick} className={cn( "absolute inset-x-0 h-px", tick === 0 && geo.zero !== null ? "bg-current/25" : "bg-current/[0.08]", )} style={{ top: `${((geo.max - tick) / (geo.max - geo.min)) * 100}%` }} /> ))} </div> <svg aria-hidden className="pointer-events-none absolute inset-0 h-full w-full overflow-visible" viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" > <defs> <linearGradient id={fillId} x1="0" x2="0" y1="0" y2="1"> <stop offset="0" style={{ stopColor: "var(--margin-note-accent)", stopOpacity: 0.2 }} /> <stop offset="1" style={{ stopColor: "var(--margin-note-accent)", stopOpacity: 0 }} /> </linearGradient> <linearGradient id={glowId} x1="0" x2="1" y1="0" y2="0"> <stop offset="0" style={{ stopColor: "var(--margin-note-accent)", stopOpacity: 0 }} /> <stop offset="1" style={{ stopColor: "var(--margin-note-accent)", stopOpacity: 0.14 }} /> </linearGradient> <clipPath id={clipId}> <rect ref={clipRef} x={-8} y={-16} width={done ? w + 16 : 0} height={h + 32} /> </clipPath> </defs> {/* a lit note's stretch, or a narrow column at its point */} {litNote && ( <motion.g key={`lit-${litNote.index}`} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={reduced ? { duration: 0 } : TIMING.light} > <rect x={bandX(litNote.from, n, w)} width={Math.max(bandX(litNote.anchor, n, w, true) - bandX(litNote.from, n, w), 2)} y={0} height={h} style={{ fill: "var(--margin-note-accent)", fillOpacity: 0.1 }} /> </motion.g> )} {/* the comparison, printed and never read */} {ghost && ( <path d={ghost} fill="none" stroke="currentColor" strokeOpacity={0.32} strokeWidth={1.25} strokeDasharray="3 4" strokeLinecap="round" vectorEffect="non-scaling-stroke" /> )} {/* the series before it's read: a faint print */} <path d={line} fill="none" stroke="currentColor" strokeOpacity={done ? 0 : 0.16} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" /> {/* what the reader has inked */} <g clipPath={`url(#${clipId})`}> {order.map((note) => note.from === note.anchor ? null : ( <rect key={`band-${note.index}`} x={bandX(note.from, n, w)} width={bandX(note.anchor, n, w, true) - bandX(note.from, n, w)} y={0} height={h} style={{ fill: "var(--margin-note-accent)", fillOpacity: 0.06 }} /> ), )} {area && <path d={area} fill={`url(#${fillId})`} />} <path d={line} fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" /> </g> {/* the glow trailing the reader */} {measured && ( <motion.g initial={false} animate={{ opacity: reading ? 1 : 0 }} transition={TIMING.reader}> <rect ref={glowRef} x={-MARGIN.glow} width={MARGIN.glow} y={0} height={h} fill={`url(#${glowId})`} /> </motion.g> )} </svg> {/* the reader */} <motion.div aria-hidden initial={false} animate={{ opacity: reading && measured ? 1 : 0 }} transition={TIMING.reader} className="pointer-events-none absolute inset-0" > <div ref={readerRef} className="absolute inset-y-0 left-0 w-px bg-[var(--margin-note-accent)]/60" /> <div ref={readerDotRef} className="absolute top-0 left-0"> <span className="absolute -top-1 -left-1 size-2 rounded-full bg-[var(--margin-note-accent)] shadow-[0_0_0_4px_color-mix(in_oklab,var(--margin-note-accent)_22%,transparent)]" /> </div> </motion.div> {/* noted points */} {order.map((note) => { const p = geo.main.points[note.anchor]; const on = note.index < shown; return ( <div key={`point-${note.index}`} aria-hidden className="pointer-events-none absolute" style={{ left: `${p.x * 100}%`, top: `${p.y * 100}%` }} > {on && !still && ( <motion.span key={`ring-${run.token}`} initial={{ opacity: 0.55, transform: "translate(-50%, -50%) scale(1)" }} animate={{ opacity: 0, transform: "translate(-50%, -50%) scale(3.2)" }} transition={TIMING.ring} className="absolute top-0 left-0 size-3 rounded-full border border-[var(--margin-note-accent)]" /> )} <motion.span initial={false} animate={ on ? { opacity: 1, transform: "translate(-50%, -50%) scale(1)" } : { opacity: 0, transform: "translate(-50%, -50%) scale(0.6)" } } transition={still ? { duration: 0 } : TIMING.pop} className={cn( `absolute top-0 left-0 flex items-center justify-center rounded-full bg-[var(--margin-note-accent)] text-[10px] leading-none font-semibold text-white tabular-nums transition-shadow ${TIMING.lightClass} dark:text-zinc-950`, "size-4 @[34rem]/margin-note:size-2.5", lit === note.index ? "shadow-[0_0_0_5px_color-mix(in_oklab,var(--margin-note-accent)_28%,transparent)]" : "shadow-[0_0_0_3px_color-mix(in_oklab,var(--margin-note-accent)_20%,transparent)]", )} > <span className="@[34rem]/margin-note:hidden">{note.index + 1}</span> </motion.span> </div> ); })} {/* the crosshair */} {cross !== null && n > 0 && ( <Crosshair x={geo.main.points[cross].x} y={geo.main.points[cross].y} label={labels?.[cross]} value={format(valueAt(cross))} compared={compare?.[cross] !== undefined ? format(compare[cross]) : undefined} /> )} </div> </div> {/* labels along the bottom */} {xLabels.length > 0 && ( <div aria-hidden className="relative mt-2 h-4 text-[11px] leading-4 text-current/55 tabular-nums" style={{ marginLeft: gutter }} > {xLabels.map((i) => { const x = geo.main.points[i].x; const edge = x < 0.04 ? "translate-x-0" : x > 0.96 ? "-translate-x-full" : "-translate-x-1/2"; return ( <span key={i} className={cn("absolute top-0 whitespace-nowrap", edge)} style={{ left: `${x * 100}%` }}> {labels?.[i]} </span> ); })} </div> )} </div> {/* the margin */} {order.length > 0 && ( <ol ref={columnRef} className={cn("relative flex flex-col gap-2.5", placed && "block")} style={placed ? { height: placed.column!.h } : undefined} > {order.map((note) => ( <NoteItem key={note.index} note={note} shown={note.index < shown && (!still || layout !== null)} still={still} dimmed={lit !== null && lit !== note.index} lit={lit === note.index} top={placed ? placed.tops[note.index] : null} onLight={(on) => setPinned(on ? note.index : null)} onToggle={() => setPinned((p) => (p === note.index ? null : note.index))} /> ))} </ol> )} {/* leaders, dotted, from each point to its note */} {placed && ( <svg aria-hidden className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"> {order.map((note) => { const p = geo.main.points[note.anchor]; const x1 = placed.plotX + p.x * placed.w + 8; const y1 = placed.plotY + p.y * placed.h; const x2 = placed.column!.x - 7; const y2 = placed.column!.y + placed.tops[note.index] + MARGIN.badge; const on = note.index < shown; // A note that stepped aside needs a bend. Run the long stretch at whichever level, the // point's or the note's, crosses the series fewer times; ties keep the point's level. const k = Math.max(0, Math.min(MARGIN.bend, (x2 - x1) / 4)); const level = (y: number) => (y - placed.plotY) / placed.h; const early = Math.abs(y2 - y1) >= 1 && crossings(geo.main, p.x, level(y2)) < crossings(geo.main, p.x, level(y1)); const d = Math.abs(y2 - y1) < 1 ? `M${round(x1)},${round(y1)}H${round(x2)}` : early ? `M${round(x1)},${round(y1)}C${round(x1 + k)},${round(y1)},${round(x1 + k)},${round(y2)},${round(x1 + 2 * k)},${round(y2)}H${round(x2)}` : `M${round(x1)},${round(y1)}H${round(x2 - 2 * k)}C${round(x2 - k)},${round(y1)},${round(x2 - k)},${round(y2)},${round(x2)},${round(y2)}`; const isLit = lit === note.index; return ( <motion.path key={`leader-${note.index}`} d={d} fill="none" strokeWidth={1.5} strokeLinecap="round" strokeDasharray="0 4" initial={false} animate={{ clipPath: on ? "inset(-8px 0% -8px 0%)" : "inset(-8px 100% -8px 0%)", opacity: lit === null || isLit ? 1 : 0.5, }} transition={still ? { duration: 0 } : { clipPath: on ? TIMING.leader : { duration: 0 }, opacity: TIMING.light }} className={cn( `transition-[stroke] ${TIMING.lightClass}`, isLit ? "stroke-[color:var(--margin-note-accent)]" : "stroke-current/30", )} /> ); })} </svg> )} </div> {/* for screen readers: the whole chart in words */} <p id={descId} className="sr-only"> {n > 0 ? `${n} points${labels?.length ? `, ${labels[0]} to ${labels[n - 1]}` : ""}, from ${format(Math.min(...data))} to ${format(Math.max(...data))}. ` : "No data. "} {order.map((note) => `Note ${note.index + 1}, at ${labels?.[note.anchor] ?? `point ${note.anchor + 1}`}: ${note.title}${note.delta ? ` (${note.delta})` : ""}. ${note.body ?? ""} `)} </p> <p className="sr-only" aria-live="polite"> {spoken} </p> </div> ); } /** The x of a point in px, or of the edge of its column when a band ends there. */ function bandX(index: number, n: number, w: number, end = false) { if (n < 2) return end ? w : 0; const step = w / (n - 1); return clamp(index * step + (end ? step / 2 : -step / 2), 0, w); } /** How many times the series crosses a level between a point and the right edge. */ function crossings(main: Series, from: number, level: number) { let count = 0; let side = 0; for (let fx = from + 0.01; fx <= 1; fx += 1 / 160) { const next = Math.sign(heightAt(main, fx) - level); if (next !== 0 && side !== 0 && next !== side) count++; if (next !== 0) side = next; } return count; } /* ── Summary ────────────────────────────────────────────── */ // The takeaway, written last: word by word when it's plain text, as one line otherwise. function Summary({ summary, shown, instant }: { summary: React.ReactNode; shown: boolean; instant: boolean }) { const words = typeof summary === "string" ? summary.split(/\s+/).filter(Boolean) : null; const step = words ? Math.min(TIMING.word, TIMING.wordSpread / Math.max(1, words.length - 1)) : 0; const hidden = { opacity: 0, transform: `translateY(${MARGIN.lift}px)` }; const visible = { opacity: 1, transform: "translateY(0px)" }; return ( <p className="mb-3 text-[15px]/6 font-medium tracking-[-0.01em] text-balance"> {words ? words.map((word, i) => ( <React.Fragment key={i}> <motion.span className="inline-block" initial={false} animate={shown ? visible : hidden} transition={instant ? { duration: 0 } : { ...TIMING.rise, delay: shown ? i * step : 0 }} > {word} </motion.span>{" "} </React.Fragment> )) : ( <motion.span className="inline-block" initial={false} animate={shown ? visible : hidden} transition={instant ? { duration: 0 } : TIMING.rise} > {summary} </motion.span> )} </p> ); } /* ── NoteItem ───────────────────────────────────────────── */ // One note in the margin: its number, title, figure and a line of detail. A button, so it can be lit // from the keyboard; level with its point once the margin is measured. function NoteItem({ note, shown, still, dimmed, lit, top, onLight, onToggle, }: { note: Note; shown: boolean; still: boolean; dimmed: boolean; lit: boolean; top: number | null; onLight: (on: boolean) => void; onToggle: () => void; }) { // A mouse lights the note on hover; a tap or Enter toggles it instead. const pointer = React.useRef(""); return ( <motion.li initial={false} animate={ shown || still ? { opacity: shown ? 1 : 0, transform: "translateX(0px)" } : { opacity: 0, transform: `translateX(-${MARGIN.slide}px)` } } // Written by the reader: slides in behind its leader. Arriving read: fades in once placed. transition={!shown ? { duration: 0 } : still ? TIMING.light : TIMING.note} className={cn(top !== null && "absolute inset-x-0", !shown && "pointer-events-none")} style={top !== null ? { top } : undefined} > <button type="button" aria-pressed={lit} tabIndex={shown ? 0 : -1} onPointerDown={(event) => { pointer.current = event.pointerType; }} onPointerEnter={(event) => { if (event.pointerType === "mouse") onLight(true); }} onPointerLeave={(event) => { if (event.pointerType === "mouse") onLight(false); }} onFocus={(event) => { if (event.currentTarget.matches(":focus-visible")) onLight(true); }} onBlur={() => onLight(false)} onClick={() => { if (pointer.current !== "mouse") onToggle(); pointer.current = ""; }} onKeyDown={(event) => { if (event.key !== "Escape") return; event.currentTarget.blur(); onLight(false); }} className={cn( `group flex w-full items-start gap-2.5 rounded-lg py-0.5 pr-1 text-left outline-offset-2 outline-[color:var(--margin-note-accent)] transition-opacity ${TIMING.lightClass} focus-visible:outline-2`, dimmed ? "opacity-45" : "opacity-100", )} > <span className="mt-px flex size-[18px] shrink-0 items-center justify-center rounded-full bg-[var(--margin-note-accent)] text-[10px] font-semibold text-white tabular-nums dark:text-zinc-950"> {note.index + 1} </span> <span className="min-w-0 flex-1"> <span className="flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5"> <span className="text-[13px]/5 font-medium tracking-[-0.005em]">{note.title}</span> {note.delta && ( <span className="rounded-full bg-[color-mix(in_oklab,var(--margin-note-accent)_14%,transparent)] px-1.5 text-[11px]/[18px] font-medium text-[color:var(--margin-note-accent)] tabular-nums"> {note.delta} </span> )} </span> {note.body && <span className="mt-0.5 block text-xs/[1.45] text-pretty text-current/60">{note.body}</span>} </span> </button> </motion.li> ); } /* ── Crosshair ──────────────────────────────────────────── */ // A hairline at the nearest point, a dot on the series and its value beside the dot: above it, or // under it when the point sits near the top. function Crosshair({ x, y, label, value, compared, }: { x: number; y: number; label?: string; value: string; compared?: string; }) { const align = x < 0.15 ? "translate-x-0" : x > 0.85 ? "-translate-x-full" : "-translate-x-1/2"; const above = y > 0.28; return ( <div aria-hidden className="pointer-events-none absolute inset-0"> <div className="absolute inset-y-0 w-px bg-current/25" style={{ left: `${x * 100}%` }} /> <div className="absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-current shadow-[0_0_0_3px_color-mix(in_oklab,currentColor_18%,transparent)]" style={{ left: `${x * 100}%`, top: `${y * 100}%` }} /> <div className={cn( "absolute flex items-baseline gap-2 rounded-md bg-zinc-950 px-2 py-1 text-[11px] leading-4 whitespace-nowrap text-white shadow-sm tabular-nums dark:bg-white dark:text-zinc-950", align, above ? "-translate-y-[calc(100%+12px)]" : "translate-y-3", )} style={{ left: `${x * 100}%`, top: `${y * 100}%` }} > {label && <span className="opacity-60">{label}</span>} <span className="font-medium">{value}</span> {compared && <span className="opacity-60">vs {compared}</span>} </div> </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Margin Note. Each one installs whole, with the component included.
Incident Read
A day of p95 latency on an API. The reader stops at the morning peak, the deploy that sent it to 842 ms and the rollback that brought it down.
api.harbor.dev / p95 latency / today
One deploy cost two slow hours at checkout. Rolling it back fixed it.
24 points, 00:00 to 23:00, from 169 ms to 842 ms. Note 1, at 09:00: Morning peak (238 ms). EU traffic tops out; p95 stays under 240 ms. Note 2, at 14:00: Deploy 4812 (+281%). An N+1 query in /orders sent p95 to 842 ms. Note 3, at 16:00: Rolled back to 4811 (−75%). Under 210 ms again by 16:00.
"use client";
import * as React from "react";
import { MarginNote, type MarginNoteNote } from "@/components/ui/margin-note";
const HOURS = Array.from({ length: 24 }, (_, h) => `${String(h).padStart(2, "0")}:00`);
const P95 = [182, 176, 171, 169, 172, 180, 196, 214, 229, 238, 231, 224, 219, 221, 842, 516, 207, 199, 194, 190, 188, 185, 183, 181];
const NOTES: MarginNoteNote[] = [
{ at: 9, title: "Morning peak", body: "EU traffic tops out; p95 stays under 240 ms.", delta: "238 ms" },
{ at: 14, title: "Deploy 4812", body: "An N+1 query in /orders sent p95 to 842 ms.", delta: "+281%" },
{ at: 15, to: 16, title: "Rolled back to 4811", body: "Under 210 ms again by 16:00.", delta: "−75%" },
];
export function MarginNoteLatencyDemo() {
const [run, setRun] = React.useState(0);
return (
<div className="w-full max-w-3xl overflow-hidden rounded-2xl bg-white text-zinc-900 shadow-sm ring-1 ring-zinc-950/[0.08] dark:bg-zinc-950 dark:text-zinc-100 dark:ring-white/10">
<div className="flex items-center justify-between gap-3 border-b border-zinc-950/[0.06] px-5 py-3 dark:border-white/[0.08]">
<div className="flex min-w-0 items-center gap-2.5">
<span className="size-2 shrink-0 rounded-full bg-emerald-500" />
<p className="truncate font-mono text-xs text-zinc-600 dark:text-zinc-400">
api.harbor.dev <span className="text-zinc-400 dark:text-zinc-600">/</span> p95 latency{" "}
<span className="text-zinc-400 dark:text-zinc-600">/</span> today
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="hidden rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-700 sm:inline dark:text-emerald-400">
Resolved 16:04
</span>
<button
type="button"
onClick={() => setRun((r) => r + 1)}
className="rounded-md px-2 py-1 font-mono text-[11px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-zinc-950 dark:hover:bg-white/10 dark:hover:text-white dark:focus-visible:outline-white"
>
replay
</button>
</div>
</div>
<div className="p-5 sm:p-6">
<MarginNote
key={run}
label="p95 latency for api.harbor.dev today"
data={P95}
labels={HOURS}
notes={NOTES}
summary="One deploy cost two slow hours at checkout. Rolling it back fixed it."
format={(value) => `${Math.round(value)} ms`}
className="[--margin-note-accent:#e11d48] dark:[--margin-note-accent:#fb7185]"
/>
</div>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content.
Margin Note: A line chart that reads itself. A reader sweeps across the series and inks it as it goes; wherever something happened it stops, marks the point and writes a note in the margin, level with the point and tied to it by a leader. Hover a note and its stretch of the chart lights up.
This demo, Incident Read: A day of p95 latency on an API. The reader stops at the morning peak, the deploy that sent it to 842 ms and the rollback that brought it down.
1. Install it:
npx shadcn@latest add @keyframeui/margin-note-latency-demo
(If the CLI can't find @keyframeui, use https://keyframeui.com/r/margin-note-latency-demo.json instead.)
It adds the demo, components/margin-note-latency-demo.tsx, and the component, components/ui/margin-note.tsx.
2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <MarginNote> on its own.
3. These props set its feel. Change them before you change the file:
- duration (default 2400): How long the read takes, in ms, pauses at the notes included.
- hold (default 280): How long the reader stops at each noted point, in ms, as if writing. The pauses never take more than 45% of `duration`.
4. Don't change, in components/ui/margin-note.tsx:
- The read is one motion `animate` whose `onUpdate` writes the clip, the glow and the reader's transforms straight to the DOM; React re-renders only when the reader passes a noted point.
- The reader holds at each noted point, the holds never take more than `holdShare` of `duration`, and between them it sweeps at one steady, linear pace.
- Every note is rendered from the start, so the margin holds its room, and each is placed level with its point once measured; nothing shifts when the notes arrive.
- Inputs are keyed by content (`dataKey`, `notesKey`), so a re-render with equal inline arrays doesn't restart the read; new data or notes read again from the start.
- Under reduced motion or `play={false}` nothing sweeps: the chart arrives read with every note in place, and highlights and the crosshair still work.
Its timing is all in the TIMING block at the top of that file.
Docs: https://keyframeui.com/components/margin-note.mdYear Against Year
Monthly revenue against last year's, dashed underneath, with notes on annual plans, a team tier and a record December.
Tinta · Revenue by month
$940k+64% on 2024
2025 closed 64% ahead of 2024, most of it after the team tier.
12 points, Jan to Dec, from $42k to $131k. Note 1, at Mar: Annual plans (+29%). Two months free on yearly billing. Note 2, at Aug: Team tier (+25%). Studios moved up; the average order rose to $212. Note 3, at Dec: Gift cards (+87%). The best December yet, 1.9× last year's.
"use client";
import * as React from "react";
import { MarginNote, type MarginNoteNote } from "@/components/ui/margin-note";
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const THIS_YEAR = [42, 45, 58, 61, 63, 79, 86, 91, 88, 94, 102, 131];
const LAST_YEAR = [38, 39, 41, 43, 44, 46, 47, 49, 48, 52, 55, 70];
const NOTES: MarginNoteNote[] = [
{ at: 2, title: "Annual plans", body: "Two months free on yearly billing.", delta: "+29%" },
{ at: 5, to: 7, title: "Team tier", body: "Studios moved up; the average order rose to $212.", delta: "+25%" },
{ at: 11, title: "Gift cards", body: "The best December yet, 1.9× last year's.", delta: "+87%" },
];
export function MarginNoteRevenueDemo() {
const [run, setRun] = React.useState(0);
return (
<div className="w-full max-w-3xl rounded-2xl bg-stone-50 p-5 text-stone-900 ring-1 ring-stone-950/[0.08] sm:p-7 dark:bg-stone-900 dark:text-stone-100 dark:ring-white/10">
<div className="mb-5 flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
<div>
<p className="text-xs font-medium text-stone-500 dark:text-stone-400">Tinta · Revenue by month</p>
<p className="mt-1 flex items-baseline gap-2">
<span className="text-2xl font-semibold tracking-tight tabular-nums">$940k</span>
<span className="text-xs font-medium text-emerald-700 tabular-nums dark:text-emerald-400">+64% on 2024</span>
</p>
</div>
<div className="flex items-center gap-4 text-xs text-stone-500 dark:text-stone-400">
<span className="flex items-center gap-1.5">
<svg width="16" height="2" aria-hidden>
<line x1="0" y1="1" x2="16" y2="1" stroke="currentColor" strokeWidth="1.5" className="text-stone-900 dark:text-stone-100" />
</svg>
2025
</span>
<span className="flex items-center gap-1.5">
<svg width="16" height="2" aria-hidden>
<line x1="0" y1="1" x2="16" y2="1" stroke="currentColor" strokeWidth="1.25" strokeDasharray="3 4" opacity="0.6" />
</svg>
2024
</span>
<button
type="button"
onClick={() => setRun((r) => r + 1)}
className="rounded-full px-2.5 py-1 font-medium text-stone-600 ring-1 ring-stone-950/10 transition-colors hover:bg-white hover:text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-stone-900 dark:text-stone-300 dark:ring-white/15 dark:hover:bg-white/5 dark:hover:text-white dark:focus-visible:outline-white"
>
Read again
</button>
</div>
</div>
<MarginNote
key={run}
label="Tinta monthly revenue, 2025 against 2024"
data={THIS_YEAR}
compare={LAST_YEAR}
labels={MONTHS}
notes={NOTES}
summary="2025 closed 64% ahead of 2024, most of it after the team tier."
format={(value) => (value === 0 ? "$0" : `$${value}k`)}
className="[--margin-note-accent:#047857] dark:[--margin-note-accent:#34d399]"
/>
</div>
);
}
Add this moment from KeyframeUI to our project and adapt it to our content.
Margin Note: A line chart that reads itself. A reader sweeps across the series and inks it as it goes; wherever something happened it stops, marks the point and writes a note in the margin, level with the point and tied to it by a leader. Hover a note and its stretch of the chart lights up.
This demo, Year Against Year: Monthly revenue against last year's, dashed underneath, with notes on annual plans, a team tier and a record December.
1. Install it:
npx shadcn@latest add @keyframeui/margin-note-revenue-demo
(If the CLI can't find @keyframeui, use https://keyframeui.com/r/margin-note-revenue-demo.json instead.)
It adds the demo, components/margin-note-revenue-demo.tsx, and the component, components/ui/margin-note.tsx.
2. Make it ours: our copy, data, images and colors. Keep the structure the motion depends on. If we only need the effect, use <MarginNote> on its own.
3. These props set its feel. Change them before you change the file:
- duration (default 2400): How long the read takes, in ms, pauses at the notes included.
- hold (default 280): How long the reader stops at each noted point, in ms, as if writing. The pauses never take more than 45% of `duration`.
4. Don't change, in components/ui/margin-note.tsx:
- The read is one motion `animate` whose `onUpdate` writes the clip, the glow and the reader's transforms straight to the DOM; React re-renders only when the reader passes a noted point.
- The reader holds at each noted point, the holds never take more than `holdShare` of `duration`, and between them it sweeps at one steady, linear pace.
- Every note is rendered from the start, so the margin holds its room, and each is placed level with its point once measured; nothing shifts when the notes arrive.
- Inputs are keyed by content (`dataKey`, `notesKey`), so a re-render with equal inline arrays doesn't restart the read; new data or notes read again from the start.
- Under reduced motion or `play={false}` nothing sweeps: the chart arrives read with every note in place, and highlights and the crosshair still work.
Its timing is all in the TIMING block at the top of that file.
Docs: https://keyframeui.com/components/margin-note.mdProps
| Prop | Type | Default | Description |
|---|---|---|---|
data Required | number[] | None | The series, one value per point, oldest first. |
labels | string[] | None | A label per point, like a date. A few run under the chart, and the crosshair shows the one it's on. |
notes | MarginNoteNote[] | [] | What the reader writes in the margin: { at, to?, title, body?, delta? }. at is an index in data; give a to for a stretch of time and its band shades, with the note written at the stretch's end. They're numbered in reading order. |
summary | React.ReactNode | None | The takeaway above the chart, written once the read ends. Plain text rises in word by word. |
format | (value: number) => string | None | Formats values on the axis and under the crosshair. Defaults to compact numbers (1.2K). |
compare | number[] | None | A second series to compare against, like last period. Drawn dashed and never read. |
play | boolean | "view" | "view" | "view" reads once the chart is 35% in view. true reads now, and again each time it flips back to true. false shows it already read. |
duration | number | 2400 | How long the read takes, in ms, pauses at the notes included. |
hold | number | 280 | How long the reader stops at each noted point, in ms, as if writing. The pauses never take more than 45% of duration. |
label | string | "Chart" | What the chart shows, like "Weekly signups". Screen readers hear it first. |
onComplete | () => void | None | Called when a read ends. |
className | string | None | Classes for the root. Set --margin-note-accent for the reader, points, bands and numbers, and --margin-note-height for the plot's height (12.5rem narrow, 15rem wide). Ink comes from the text color. |
dataRequiredType
number[]The series, one value per point, oldest first.
labelsType
string[]A label per point, like a date. A few run under the chart, and the crosshair shows the one it's on.
notesType
MarginNoteNote[]Default[]What the reader writes in the margin:
{ at, to?, title, body?, delta? }.atis an index indata; give atofor a stretch of time and its band shades, with the note written at the stretch's end. They're numbered in reading order.summaryType
React.ReactNodeThe takeaway above the chart, written once the read ends. Plain text rises in word by word.
formatType
(value: number) => stringFormats values on the axis and under the crosshair. Defaults to compact numbers (1.2K).
compareType
number[]A second series to compare against, like last period. Drawn dashed and never read.
playType
boolean | "view"Default"view""view"reads once the chart is 35% in view.truereads now, and again each time it flips back totrue.falseshows it already read.durationType
numberDefault2400How long the read takes, in ms, pauses at the notes included.
holdType
numberDefault280How long the reader stops at each noted point, in ms, as if writing. The pauses never take more than 45% of
duration.labelType
stringDefault"Chart"What the chart shows, like "Weekly signups". Screen readers hear it first.
onCompleteType
() => voidCalled when a read ends.
classNameType
stringClasses for the root. Set
--margin-note-accentfor the reader, points, bands and numbers, and--margin-note-heightfor the plot's height (12.5rem narrow, 15rem wide). Ink comes from the text color.