Exclusivebeta
Marginalia — <mv-marginalia>
Responsive sidenotes for long-form content (docs, articles, legal terms, changelogs, research reports) where each note decides for itself whether the margin can hold it. Wrap the text, then write notes inline with <span data-note> at the exact point they discuss, as <aside data-note> after a paragraph, or keep your Markdown footnotes (GFM, markdown-it, Pandoc), which are adopted automatically. On a wide container the text keeps its measure and a margin rail takes the space left over (container units, no breakpoint to maintain); every note sits in the rail aligned with its line, stacked notes push each other down and never overlap, blocks marked data-note-wide are stepped around, long notes are clamped with “More”, and notes that hang past the last line extend the container instead of overflowing. As the rail narrows, each note is measured at the width it would get: one that would run longer than max-lines, or that collisions pushed more than max-drift away from its line, leaves the margin and becomes a numbered toggle that opens right below its line (a float, so the line itself is never split), while short notes stay in the margin. Hovering or focusing a marker, a note or its anchor text highlights all three and draws a thin leader from the line to the note. Kinds (note, source, warning) carry a text label and a distinct rule style, not only a color. Without JS the notes are readable in place (floated into the margin when there is room), and printing turns every note into a numbered footnote list after the text.
| Category | Layout |
|---|---|
| Type | Web Component (<mv-marginalia>) |
| Status | beta |
| Keywords | exclusive, culture, sidenotes, sidenote, marginalia, margin-notes, footnotes, annotations, citations, long-form, prose, article, documentation, typography, responsive, container-queries, print, markdown, collision-avoidance |
When to use
- Documentation or a blog post has asides, sources and caveats that should sit next to the line they discuss instead of at the bottom
- Markdown footnotes should become sidenotes on wide screens without rewriting the content or the Markdown pipeline
- Legal terms, policies or research reports need citations and warnings in the margin that still work on phones and on paper
- A changelog or release note needs short context (migration hints, deprecations) beside each entry without bloating the main text
Avoid when
- The extra text is a short hint or label for a control, not an aside in running prose → use Tooltip instead
- The aside holds actions or form fields, or must float above the app UI rather than sit in the document flow → use Popover instead
- The page is a dense app screen or a narrow column with no reading measure to spare; a margin rail only pays off in long-form text
Install
node scripts/add.mjs marginalia --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["marginalia"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/motion.js, core/observe.js, components/marginalia/marginalia.js, components/marginalia/marginalia.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="mg-demo" style="width:min(100%,64rem);margin-inline:auto">
<style>
#mg-demo { display:grid; gap:1rem }
#mg-demo .mg-bar { display:flex; align-items:center; gap:.75rem 1.5rem; flex-wrap:wrap; padding:.75rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#mg-demo .mg-width { display:flex; align-items:center; gap:.75rem; flex:1 1 20rem; min-width:0 }
#mg-demo .mg-width label { font-size:.8125rem; font-weight:500; white-space:nowrap }
#mg-demo .mg-width input { flex:1; min-width:6rem }
#mg-demo .mg-width output { min-width:3.75rem; color:var(--mv-fg-muted); font-size:.75rem; font-variant-numeric:tabular-nums; text-align:end }
#mg-demo .mg-status { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap }
#mg-demo .mg-stage { display:flex; justify-content:center; min-width:0 }
#mg-demo .mg-doc { width:var(--mg-w,100%); max-width:100%; box-sizing:border-box; padding:2rem clamp(1rem,4vw,2.5rem) 2.25rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); transition:width var(--mv-duration-normal) var(--mv-ease-out) }
#mg-demo .mg-crumbs { margin:0 0 .5rem; color:var(--mv-fg-subtle); font-size:.75rem; letter-spacing:.02em }
#mg-demo mv-marginalia { color:var(--mv-fg); font-size:.9375rem; line-height:1.7; --mv-marginalia-measure:39rem; --mv-marginalia-measure-min:26rem; --mv-marginalia-width:17rem }
#mg-demo mv-marginalia h2 { margin:0 0 .375rem; font-size:1.5rem; line-height:1.25; letter-spacing:-.02em }
#mg-demo mv-marginalia h3 { margin:1.75rem 0 .5rem; font-size:1.0625rem; letter-spacing:-.01em }
#mg-demo mv-marginalia p { margin:0 0 1rem }
#mg-demo mv-marginalia .mg-meta { margin:0 0 1.5rem; color:var(--mv-fg-muted); font-size:.8125rem }
#mg-demo mv-marginalia code { padding:.05em .3em; border-radius:var(--mv-radius-xs); background:var(--mv-bg-muted); font:.86em var(--mv-font-mono) }
#mg-demo mv-marginalia ol.mg-steps { margin:0 0 1rem; padding-inline-start:1.25rem }
#mg-demo mv-marginalia ol.mg-steps li { margin-bottom:.375rem }
#mg-demo mv-marginalia pre { margin:0 0 1.25rem; padding:.875rem 1rem; overflow:auto; border:1px solid var(--mv-border); border-radius:var(--mv-radius-lg); background:var(--mv-bg-subtle); font:.8125rem/1.6 var(--mv-font-mono) }
#mg-demo mv-marginalia pre code { padding:0; background:none; font:inherit }
#mg-demo mv-marginalia section[data-footnotes] { margin-top:1.5rem; padding-top:.75rem; border-top:1px solid var(--mv-border); font-size:.8125rem; color:var(--mv-fg-muted) }
#mg-demo .mg-hint { margin:0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
</style>
<div class="mg-bar">
<div class="mg-width">
<label for="mg-range">Reading width</label>
<input type="range" class="mv-slider" id="mg-range" min="340" max="1024" step="4" value="1024">
<output id="mg-out" for="mg-range">Full</output>
</div>
<div class="mg-status">
<mv-segmented id="mg-mode" label="Note placement" value="auto">
<button value="auto">Auto</button>
<button value="inline">Inline only</button>
</mv-segmented>
<span class="mv-badge" data-variant="secondary" data-shape="pill" id="mg-count">–</span>
</div>
</div>
<div class="mg-stage">
<article class="mg-doc" id="mg-doc" aria-labelledby="mg-title">
<p class="mg-crumbs">Docs / Security / API keys</p>
<mv-marginalia id="mg-notes">
<h2 id="mg-title">Rotating API keys without downtime</h2>
<p class="mg-meta">Updated September 18, 2026 · 6 min read</p>
<p>Every API key in your workspace is a long-lived secret. Rotating keys on a schedule limits the damage a leaked key can do<span data-note data-note-kind="source">NIST SP 800-57 Part 1, Rev. 5, §5.3 recommends cryptoperiods of one to two years for authentication keys.</span>, and most compliance programs ask for it. Scope each key to one service so a rotation never touches more than it has to, following the principle of <span data-note-anchor>least privilege</span><span data-note>A key used by billing should not be able to read customer files. Scopes are set once, at creation.</span>.</p>
<h3>How rotation works</h3>
<p>Rotation is a two-key handover. You create a successor key, deploy it everywhere the current key is used, then revoke the old key once traffic has moved over. During the overlap both keys authenticate normally<span data-note>The overlap exists so you never have to deploy every service at the same instant. Both keys share the same scopes, rate limits and audit trail, and each request records which of the two signed it, so you can see migration progress in the key's Activity tab. Keys created before March 2024 use the legacy <code>nk_</code> prefix: they rotate the same way, but their successor gets the current <code>sk_live_</code> format, so check any code that validates keys by prefix. If you use infrastructure as code, create the successor in the same change that updates the secret reference, and keep the old key out of the plan until traffic has drained; Terraform and Pulumi providers both expose a <code>rotation_window</code> argument for this. When the overlap ends before you revoke, the old key keeps working: nothing expires on its own, which is why the audit log flags keys that have been in rotation for more than 30 days.</span>, so no request fails while you roll out.</p>
<ol class="mg-steps">
<li>Create the successor key from <strong>Settings → API keys → Rotate</strong>.</li>
<li>Update the secret in every environment, starting with staging.</li>
<li>Watch the old key's traffic drop to zero, then revoke it.</li>
</ol>
<aside data-note data-note-kind="warning">Revoking is immediate and cannot be undone. Requests still signed with the old key fail with <code>401 Unauthorized</code>.</aside>
<pre data-note-wide><code>curl -X POST https://api.northwind.dev/v2/keys/key_7Hq2/rotate \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d overlap=72h -d [email protected]</code></pre>
<h3>Choosing an overlap window</h3>
<p>A 72-hour overlap covers weekly deploy trains and most mobile release cycles<span data-note>Mobile apps are the slow path: a key baked into a build lives as long as users keep that version installed. Ship keys to mobile clients through your backend instead, so rotating one never waits on the App Store or Google Play review, and old app versions keep working without a forced update. If you must embed a key, give it read-only scopes and rotate it with every major release.</span>. Long-running batch jobs are the usual exception: a nightly export that starts with the old key keeps using it until it finishes<span data-note data-note-kind="source">Measured across 1,240 workspaces in Q2 2026: the median export job runs for 41 minutes, the 99th percentile for 7.5 hours.</span>, so revoke after the last run that began inside the window.</p>
<p>Teams on the Enterprise plan can schedule rotations and get a Slack or email reminder before each one<sup><a href="#mg-fn-1" id="mg-fnref-1" data-footnote-ref>1</a></sup>. Scheduled rotations use the same overlap window as manual ones.</p>
<section data-footnotes class="footnotes" aria-label="Footnotes">
<ol>
<li id="mg-fn-1"><p>Reminders go to workspace owners and to anyone subscribed to the <em>security</em> channel, 7 days and 24 hours ahead. <a href="#mg-fnref-1" data-footnote-backref aria-label="Back to reference 1">↩</a></p></li>
</ol>
</section>
</mv-marginalia>
</article>
</div>
<p class="mg-hint">Narrow the page: long notes leave the margin first and become numbered toggles under their line · Hover a note or its number to link them</p>
<script type="module">
const notes = document.getElementById("mg-notes");
const doc = document.getElementById("mg-doc");
const range = document.getElementById("mg-range");
const out = document.getElementById("mg-out");
const count = document.getElementById("mg-count");
const mode = document.getElementById("mg-mode");
const fit = () => {
const max = Math.floor(doc.parentElement.clientWidth);
range.max = String(Math.max(360, max));
const full = Number(range.value) >= Number(range.max);
doc.style.setProperty("--mg-w", full ? "100%" : `${range.value}px`);
out.textContent = full ? "Full" : `${range.value} px`;
};
range.addEventListener("input", fit);
new ResizeObserver(fit).observe(doc.parentElement);
fit();
mode.addEventListener("mv-change", (e) => { notes.mode = e.detail.value; });
const paint = () => {
const list = notes.notes;
if (!list.length) return;
const m = list.filter((n) => n.mode === "margin").length;
count.textContent = notes.layout === "margin" ? `${m} in the margin · ${list.length - m} inline` : `All ${list.length} inline`;
count.dataset.variant = notes.layout === "margin" ? "info" : "secondary";
};
notes.addEventListener("mv-layout", paint);
await customElements.whenDefined("mv-marginalia");
requestAnimationFrame(paint);
</script>
</div>Cultural reference
Fermat's Last Theorem (margin note in Diophantus' Arithmetica) — Pierre de Fermat (1637, book). Fermat wrote his aside in the margin of the book, next to the problem it discussed, and noted that the margin was too narrow to contain the proof. In the UI, every note sits in the margin beside its line when the margin can hold it, and a note the margin is too narrow to contain folds into a numbered toggle that opens right below its line.
API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
mode | "auto" | "inline" | auto | "auto": notes go to the margin when the rail is at least --mv-marginalia-min wide, each one then checked for length and drift. "inline": every note is a numbered toggle, whatever the width (narrow sidebars, embeds). |
side | "end" | "start" | end | Which side of the text the margin rail is on (logical: end = right in LTR, left in RTL). |
max-lines | number | 12 | A note whose full text would run longer than this many lines at the current rail width is too long for the margin and becomes an inline toggle. 0 = no limit (long notes are only clamped). |
clamp | number | 6 | Margin notes longer than this many lines (plus one, so a single line is never hidden) are clamped with a fade and a “More” button that expands them in place, pushing the following notes down. 0 = never clamp. |
max-drift | number (px) | 360 | When collisions push a margin note further than this below its line, the reader would lose the link: the note becomes an inline toggle instead. 0 = no limit. |
footnotes | "adopt" | "off" | adopt | Adopts Markdown footnotes as sidenotes: references matching a[data-footnote-ref], a[role="doc-noteref"], a.footnote-ref or .footnote-ref > a pointing at an id inside the page. The item's content is cloned without its back-link, the original reference is hidden, and a footnote list whose every item was adopted is hidden on screen. |
data-note | on a child element | Marks a note. An inline element (span, small…) is anchored to the exact point where it is written; a block (aside, div, p…) is anchored to the start of the preceding block and its marker is appended to that block's text. | |
data-note-kind | "note" | "source" | "warning" | string | note | On a note: kind shown as a text label after the number (none for plain notes) and as a rule style (dashed for source, warning color for warning). Custom kinds are labeled with their capitalized name or strings[kind]. |
data-note-anchor | on an element right before an inline note | Marks the exact words the note is about: dotted underline, highlighted with the note, and used in the note's accessible name. data-note-for="id" on the note points at any other anchor element. | |
data-note-marker | string | On a note: custom marker text ("*", "a", "†") instead of its number. | |
data-note-mode | "inline" | On a note: always an inline toggle, even when it would fit in the margin. | |
data-note-wide | on a direct child | Full-bleed block (code sample, figure, table) that runs under the rail; margin notes are pushed below it. | |
data-layout | "margin" | "inline" | Set by the component (styleable). data-settled is added after the first layout: position changes animate from then on. |
Properties
| Name | Type | Description |
|---|---|---|
notes | Array<{ id, index, marker, kind, mode, reason, open, clamped, expanded, note, ref, anchorText }> | Snapshot of every note in reading order (read-only). mode: "margin" | "inline"; reason why a note is inline: "length" (too long for this rail), "crowded" (pushed past max-drift), "width" (no rail at all), "mode", "author" (data-note-mode). |
layout | "margin" | "inline" | null | Current layout of the container (read-only). |
strings | Partial<Record<string, string>> | Overrides for visible text and accessible names. Keys: note, source, warning (and any custom kind), more, less, name ("{kind} {n}"), nameOn ("{kind} {n}, on “{anchor}”"), printTitle. English defaults. |
mode / side / maxLines / clamp / maxDrift / footnotes | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
refresh() | Scans for notes added since (also done automatically by a MutationObserver) and lays everything out again synchronously. |
reveal(target) | Scrolls the note's line into view, then focuses the note in the margin (with a brief highlight) or opens it inline. target: 1-based index, note id, selector or element. Returns false when not found. |
toggle(target, force?) | Opens or closes an inline note, or expands / re-clamps a long margin note. Returns the new state. |
Events
| Name | Description |
|---|---|
mv-layout | The layout or the margin/inline split changed. detail: { layout, railWidth, margin, inline }. |
mv-note-mode | A note moved between the margin and inline. detail: { note, index, mode, reason }. |
mv-note-toggle | Cancelable, before an inline note opens or closes, or a clamped note expands or collapses. detail: { note, index, open, via: "marker" | "more" | "escape" | "api" }. |
Content structure
| Name | Description |
|---|---|
(content) | Your long-form markup. Direct children form the text column (max-width set by the component); notes are restyled in place, never moved out of their paragraph, so the reading order stays intact. |
CSS classes
| Name | Description |
|---|---|
mv-marginalia-note | Added to each [data-note]: role="note", data-mode="margin | inline", data-long / data-clamped, data-open (inline), data-active while linked. Contains .mv-marginalia-text (.mv-marginalia-label with -num and -kind, then .mv-marginalia-body with your content) and .mv-marginalia-more. |
mv-marginalia-ref | Generated marker (<a href="#note">) placed where the note is anchored, with .mv-marginalia-ref-num inside; data-mode, role="button" + aria-expanded in inline mode, data-active while linked. |
mv-marginalia-anchor | Added to the note's anchor element; data-active while linked. |
mv-marginalia-leader | SVG overlay (aria-hidden) drawing the line from the text to the active margin note. |
mv-marginalia-print | Footnote list built on beforeprint, displayed only in print (-title, -list, -num, -kind). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-marginalia-measure | 40rem | Maximum width of the text column. |
--mv-marginalia-measure-min | 28rem | The text column shrinks down to this width to make room for the rail before the rail itself narrows. |
--mv-marginalia-width | 16rem | Maximum width of the margin rail. |
--mv-marginalia-gap | 2.5rem | Space between the text and the rail (where the leader is drawn). |
--mv-marginalia-min | 8rem | Narrowest rail worth using; below it every note is inline and the text takes the full measure. |
--mv-marginalia-spacing | 0.875rem | Minimum vertical space between stacked margin notes. |
--mv-marginalia-accent | var(--mv-accent) | Markers, numbers, active rule, leader and “More”. |
--mv-marginalia-rule | var(--mv-border-strong) | Resting rule on the note's edge and anchor underline. |
--mv-marginalia-warning | var(--mv-warning) | Rule color of warning notes. |
Accessibility
Notes stay in their source position in the DOM, so screen readers meet them in reading order, and each one is role="note" with an accessible name that points back to what it discusses (“Source 2, on “least privilege””: the data-note-anchor words, else the words just before the marker, else the start of the paragraph for block notes). The generated marker is a real link (<a href="#note">, named “Note 3”) in margin mode: Enter moves focus to the note (tabindex=-1, focus-visible ring, brief highlight, scrolled into view only if needed) and Escape brings focus back to the marker; from a focused note, ArrowDown and ArrowUp walk to the next or previous note. In inline mode the same marker becomes role="button" with aria-expanded and aria-controls, toggled by Enter or Space; the note opens right after the line and Escape closes it and returns focus to the marker. Clamped notes have a real “More” / “Less” button with aria-expanded. Hover and focus highlight the marker, the note and the anchor together, and the kind is always written (Source, Warning) and drawn as a rule style, never conveyed by color alone. The leader and rail probe are aria-hidden with pointer-events: none. Without JS the notes are plain readable text in place. Reduced motion (prefers-reduced-motion or data-motion="reduce"): notes jump to their new position, no open animation, no leader draw-in. Forced colors: rules use CanvasText, the active note and open markers use Highlight, markers use LinkText, the leader is hidden. Print: notes are removed from the text and listed as numbered footnotes after it.