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.

CategoryLayout
TypeWeb Component (<mv-marginalia>)
Statusbeta
Keywordsexclusive, 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

Avoid when

Install

node scripts/add.mjs marginalia --out ./src/marvelous

AI 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

NameTypeDefaultDescription
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"endWhich side of the text the margin rail is on (logical: end = right in LTR, left in RTL).
max-linesnumber12A 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).
clampnumber6Margin 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-driftnumber (px)360When 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"adoptAdopts 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-noteon a child elementMarks 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" | stringnoteOn 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-anchoron an element right before an inline noteMarks 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-markerstringOn 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-wideon a direct childFull-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

NameTypeDescription
notesArray<{ 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" | nullCurrent layout of the container (read-only).
stringsPartial<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 / footnotesreflectedMirror the attributes.

Methods

NameDescription
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

NameDescription
mv-layoutThe layout or the margin/inline split changed. detail: { layout, railWidth, margin, inline }.
mv-note-modeA note moved between the margin and inline. detail: { note, index, mode, reason }.
mv-note-toggleCancelable, 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

NameDescription
(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

NameDescription
mv-marginalia-noteAdded 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-refGenerated 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-anchorAdded to the note's anchor element; data-active while linked.
mv-marginalia-leaderSVG overlay (aria-hidden) drawing the line from the text to the active margin note.
mv-marginalia-printFootnote list built on beforeprint, displayed only in print (-title, -list, -num, -kind).

CSS variables

NameDefaultDescription
--mv-marginalia-measure40remMaximum width of the text column.
--mv-marginalia-measure-min28remThe text column shrinks down to this width to make room for the rail before the rail itself narrows.
--mv-marginalia-width16remMaximum width of the margin rail.
--mv-marginalia-gap2.5remSpace between the text and the rail (where the leader is drawn).
--mv-marginalia-min8remNarrowest rail worth using; below it every note is inline and the text takes the full measure.
--mv-marginalia-spacing0.875remMinimum vertical space between stacked margin notes.
--mv-marginalia-accentvar(--mv-accent)Markers, numbers, active rule, leader and “More”.
--mv-marginalia-rulevar(--mv-border-strong)Resting rule on the note's edge and anchor underline.
--mv-marginalia-warningvar(--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.