"use client";
import * as React from "react";
export type SidenotesSide = "right" | "left";
export type SidenotesNumbering = "decimal" | "lower-alpha" | "lower-roman";
type Layout = "margin" | "inline";
export interface SidenotesProps extends React.ComponentPropsWithoutRef<"div"> {
/** Container width (px) from which notes move into the margin. Narrower containers show inline disclosures. */
breakpoint?: number;
/** Width of the margin column in px. */
gutter?: number;
/** Space between the text column and the margin column in px. */
gap?: number;
/** Which margin holds the notes. */
side?: SidenotesSide;
/** Counter style shared by references and notes. */
numbering?: SidenotesNumbering;
/** Minimum vertical space between stacked notes in px. */
spacing?: number;
/** Highlight and focus colour (any CSS colour). */
accent?: string;
}
export interface SidenoteProps extends Omit<React.ComponentPropsWithoutRef<"span">, "children"> {
/** The note itself. Phrasing content (text, links, <em>…) — it is rendered inside the paragraph. */
note: React.ReactNode;
/** The referenced words in the body text. */
children: React.ReactNode;
/** Start expanded when the container is narrow. */
defaultOpen?: boolean;
}
type Entry = { anchor: HTMLElement; note: HTMLElement };
/**
* Mutable registry of mounted sidenotes. Positions are written straight to the DOM — never React state.
* It also numbers the notes in document order (the same order the CSS counter uses) so each reference
* button gets an accessible name like "Note 3".
*/
class NoteRegistry {
private entries = new Map<string, Entry>();
private numbers = new Map<string, number>();
private subscribers = new Set<() => void>();
private layoutListener: () => void = () => {};
add(id: string, entry: Entry) {
this.entries.set(id, entry);
this.renumber();
}
remove(id: string) {
this.entries.delete(id);
this.renumber();
}
list() {
return Array.from(this.entries.values());
}
numberOf(id: string) {
return this.numbers.get(id) ?? 0;
}
/** For useSyncExternalStore: notified whenever the numbering may have changed. */
subscribe = (callback: () => void) => {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
};
/** The provider's layout pass, run whenever a note mounts or unmounts. */
listen(listener: () => void) {
this.layoutListener = listener;
return () => {
this.layoutListener = () => {};
};
}
private renumber() {
const ordered = Array.from(this.entries).sort(([, a], [, b]) =>
a.anchor.compareDocumentPosition(b.anchor) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
);
this.numbers = new Map(ordered.map(([id], i) => [id, i + 1]));
this.subscribers.forEach((callback) => callback());
this.layoutListener();
}
}
const noopSubscribe = () => () => {};
interface SidenotesContextValue {
layout: Layout;
side: SidenotesSide;
activeId: string | null;
setActiveId: (id: string | null) => void;
registry: NoteRegistry | null;
}
const SidenotesContext = React.createContext<SidenotesContextValue>({
layout: "inline",
side: "right",
activeId: null,
setActiveId: () => {},
registry: null,
});
const round = (n: number) => Math.round(n * 100) / 100;
/**
* Provider for editorial sidenotes. On wide containers each note sits in the margin, aligned with the
* line of its reference; notes that would overlap are pushed down so they stack. On narrow containers
* (measured with ResizeObserver, so it responds to the container, not the viewport) every reference
* becomes a disclosure button that expands its note inline. Numbering uses a CSS counter, so the
* reference marks and the notes always agree — on the server too.
*/
export function Sidenotes({
breakpoint = 720,
gutter = 200,
gap = 40,
side = "right",
numbering = "decimal",
spacing = 12,
accent = "#FF7448",
className = "",
style,
children,
...props
}: SidenotesProps) {
const rootRef = React.useRef<HTMLDivElement>(null);
const [registry] = React.useState(() => new NoteRegistry());
const [layout, setLayout] = React.useState<Layout>("inline");
const [activeId, setActiveId] = React.useState<string | null>(null);
React.useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
const desiredLayout = (): Layout => (root.getBoundingClientRect().width >= breakpoint ? "margin" : "inline");
/** Aligns every note with its reference line, then pushes overlapping notes down. */
const position = () => {
const entries = registry.list();
if (layout !== "margin") {
for (const { note } of entries) {
note.style.top = "";
note.style.left = "";
note.style.width = "";
}
root.style.minHeight = "";
return;
}
const rootRect = root.getBoundingClientRect();
// Rects include CSS transforms (e.g. a scaled preview); offsets don't. Convert back to layout px.
const scale = root.offsetWidth ? rootRect.width / root.offsetWidth : 1;
const items = entries
.map((entry) => {
const lines = entry.anchor.getClientRects();
const line = lines[lines.length - 1] ?? entry.anchor.getBoundingClientRect();
return { entry, desired: (line.top - rootRect.top) / scale };
})
.sort((a, b) => a.desired - b.desired);
let cursor = -Infinity;
let bottom = 0;
for (const { entry, desired } of items) {
const { note } = entry;
const top = Math.max(desired, cursor);
const parent = (note.offsetParent as HTMLElement | null) ?? root;
const parentRect = parent.getBoundingClientRect();
// Offsets of the note's containing block relative to the provider, in layout px.
const dx = (parentRect.left - rootRect.left) / scale + parent.clientLeft;
const dy = (parentRect.top - rootRect.top) / scale + parent.clientTop;
const x = side === "right" ? root.offsetWidth - gutter : 0;
note.style.width = `${gutter}px`;
note.style.left = `${round(x - dx)}px`;
note.style.top = `${round(top - dy)}px`;
const height = note.offsetHeight;
cursor = top + height + spacing;
bottom = Math.max(bottom, top + height);
}
// Keep a long final note from spilling out of the article.
const minHeight = `${Math.ceil(bottom)}px`;
if (root.style.minHeight !== minHeight) root.style.minHeight = minHeight;
};
const initial = desiredLayout();
if (initial !== layout) {
// Layout depends on the measured container width, which only exists in the browser.
setLayout(initial);
return;
}
position();
let frame = 0;
const schedule = () => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
const next = desiredLayout();
if (next !== layout) setLayout(next);
else position();
});
};
const stopListening = registry.listen(() => {
schedule();
});
const ro = new ResizeObserver(schedule);
ro.observe(root);
for (const { note } of registry.list()) ro.observe(note);
let disposed = false;
document.fonts?.ready.then(() => {
if (!disposed) schedule();
});
return () => {
disposed = true;
cancelAnimationFrame(frame);
ro.disconnect();
stopListening();
};
}, [registry, layout, breakpoint, gutter, side, spacing, children]);
const margin = layout === "margin";
const value: SidenotesContextValue = { layout, side, activeId, setActiveId, registry };
return (
<SidenotesContext.Provider value={value}>
<div
ref={rootRef}
data-layout={layout}
{...props}
className={`relative [counter-reset:sidenote] ${className}`}
style={{
["--sn-accent" as string]: accent,
["--sn-numbering" as string]: numbering,
paddingRight: margin && side === "right" ? gutter + gap : undefined,
paddingLeft: margin && side === "left" ? gutter + gap : undefined,
...style,
}}
>
{children}
</div>
</SidenotesContext.Provider>
);
}
const ring =
"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sn-accent)]";
/**
* A reference in the body text plus its note. Use inside <Sidenotes>. In the margin layout the
* numbered mark is a button described by its note (aria-describedby); in the inline layout it is a
* disclosure button (aria-expanded + aria-controls) that opens the note below the line.
*/
export function Sidenote({
note,
children,
defaultOpen = false,
className = "",
onPointerEnter,
onPointerLeave,
...props
}: SidenoteProps) {
const { layout, side, activeId, setActiveId, registry } = React.useContext(SidenotesContext);
const id = React.useId();
const noteId = `${id}-note`;
const [open, setOpen] = React.useState(defaultOpen);
// Document-order number for the accessible name; the visible digit comes from the CSS counter.
const number = React.useSyncExternalStore(
registry ? registry.subscribe : noopSubscribe,
() => registry?.numberOf(id) ?? 0,
() => 0,
);
const anchorRef = React.useRef<HTMLSpanElement>(null);
const noteRef = React.useRef<HTMLSpanElement>(null);
React.useLayoutEffect(() => {
const anchor = anchorRef.current;
const noteEl = noteRef.current;
if (!registry || !anchor || !noteEl) return;
registry.add(id, { anchor, note: noteEl });
return () => registry.remove(id);
}, [registry, id]);
const margin = layout === "margin";
const active = activeId === id;
const text = typeof children === "string" ? children.trimEnd() : null;
const cut = text ? text.lastIndexOf(" ") : -1;
const expanded = margin || open;
const activate = () => setActiveId(id);
const deactivate = () => {
if (activeId === id) setActiveId(null);
};
const mark = (
<button
type="button"
aria-label={number ? `Note ${number}` : undefined}
aria-describedby={margin ? noteId : undefined}
aria-expanded={margin ? undefined : open}
aria-controls={margin ? undefined : noteId}
onFocus={activate}
onBlur={deactivate}
onClick={() => {
if (margin) activate();
else setOpen((value) => !value);
}}
onKeyDown={(event) => {
if (event.key === "Escape" && !margin && open) {
event.preventDefault();
setOpen(false);
}
}}
className={`relative -top-[0.5em] ml-[0.06em] inline-flex min-w-[1.2em] cursor-pointer items-center justify-center rounded-[4px] px-[0.2em] align-baseline font-mono text-[0.62em] font-medium leading-[1.4] text-[var(--sn-accent)] no-underline transition-colors duration-200 motion-reduce:transition-none ${
!margin && open ? "bg-[var(--sn-accent)] text-white dark:text-zinc-950" : "hover:bg-[color-mix(in_oklab,var(--sn-accent)_14%,transparent)]"
} ${ring}`}
>
<span className="sr-only">Note </span>
<span className="[counter-increment:sidenote] after:content-[counter(sidenote,var(--sn-numbering))]" />
</button>
);
return (
<>
<span
ref={anchorRef}
{...props}
data-active={active || undefined}
className={`rounded-[3px] underline decoration-1 underline-offset-[0.22em] transition-[background-color,text-decoration-color] duration-200 [box-decoration-break:clone] [-webkit-box-decoration-break:clone] motion-reduce:transition-none ${
active
? "bg-[color-mix(in_oklab,var(--sn-accent)_14%,transparent)] decoration-[var(--sn-accent)]"
: "decoration-zinc-900/25 dark:decoration-white/25"
} ${className}`}
onPointerEnter={(event) => {
onPointerEnter?.(event);
if (event.pointerType !== "touch") activate();
}}
onPointerLeave={(event) => {
onPointerLeave?.(event);
deactivate();
}}
>
{text !== null ? (
<>
{text.slice(0, cut + 1)}
{/* The last word and the mark never wrap apart. */}
<span className="whitespace-nowrap">
{text.slice(cut + 1)}
{mark}
</span>
</>
) : (
<>
{children}
{mark}
</>
)}
</span>
<span
ref={noteRef}
id={noteId}
role="note"
data-active={active || undefined}
onPointerEnter={(event) => {
if (event.pointerType !== "touch") activate();
}}
onPointerLeave={deactivate}
className={
margin
? `absolute top-0 z-[1] block text-[max(11px,0.78em)] leading-[1.55] font-normal tracking-normal transition-[color,border-color] duration-200 motion-reduce:transition-none ${
side === "right" ? "border-l pl-3 text-left" : "border-r pr-3 text-right"
} ${
active
? "border-[var(--sn-accent)] text-zinc-900 dark:text-zinc-100"
: "border-zinc-900/12 text-zinc-600 dark:border-white/12 dark:text-zinc-400"
}`
: // Collapsed notes are display:none so they never break the line. Opening animates the rows
// from 0fr via @starting-style; closing keeps the box (discrete display) until 0fr.
`transition-[grid-template-rows,display] transition-discrete duration-300 ease-[cubic-bezier(0.2,0.7,0.2,1)] motion-reduce:transition-none ${
expanded ? "grid grid-rows-[1fr] starting:grid-rows-[0fr]" : "hidden grid-rows-[0fr]"
}`
}
>
<span className={margin ? "block font-normal" : "block min-h-0 overflow-hidden"}>
<span
className={
margin
? "block"
: `my-2.5 block rounded-r-md border-l-2 border-[var(--sn-accent)] bg-zinc-900/[0.035] py-2 pl-3 pr-3 text-[0.82em] leading-[1.55] font-normal tracking-normal text-zinc-700 dark:bg-white/[0.04] dark:text-zinc-300`
}
>
<span
aria-hidden="true"
className="mr-1.5 font-mono text-[0.85em] font-medium text-[var(--sn-accent)] before:content-[counter(sidenote,var(--sn-numbering))]"
/>
{note}
</span>
</span>
</span>
</>
);
}