Today I figured out how to add Tufte-style sidenotes1 Like this one! Named after Edward Tufte, who popularized margin notes in his beautifully typeset books on data visualization. to my Astro blog. The goal: keep using standard Markdown footnote syntax, but display the notes in the margin instead of at the bottom of the page.

I decided I needed to finally make this change after reading Maggie Appleton’s blog post about Gastown.

The approach

Astro’s markdown pipeline uses remark (for parsing) and rehype (for HTML transformation). Astro includes remark-gfm by default, which gives you GitHub-flavored Markdown features including footnote syntax ([^1]). Since footnotes are already parsed, I wrote a rehype plugin that restructures the HTML output.

The plugin does three things:

  1. Collects all footnote definitions from the <section data-footnotes> at the bottom
  2. Replaces each footnote reference (<sup>) with inline sidenote markup
  3. Removes the original footnotes section

The rehype plugin

// src/plugins/rehype-sidenotes.js
import { visit } from 'unist-util-visit';

export default function rehypeSidenotes() {
  return (tree) => {
    const footnotes = new Map();

    // First pass: collect footnote definitions
    visit(tree, 'element', (node) => {
      if (node.tagName === 'section' && 
          node.properties?.dataFootnotes !== undefined) {
        const ol = node.children?.find(
          (child) => child.tagName === 'ol'
        );
        // Extract content from each <li>, store by number
        // (full implementation omitted for brevity)
      }
    });

    // Second pass: replace <sup> refs with sidenote markup
    visit(tree, 'element', (node, index, parent) => {
      if (node.tagName === 'sup') {
        // Find the footnote link, extract the number,
        // replace with sidenote wrapper
      }
    });

    // Third pass: remove the footnotes section
    visit(tree, 'element', (node, index, parent) => {
      if (node.tagName === 'section' && 
          node.properties?.dataFootnotes !== undefined) {
        parent.children.splice(index, 1);
      }
    });
  };
}

The HTML structure

The plugin transforms each footnote reference into this markup:

<span class="sidenote-wrapper">
  <label for="sn-1" class="sidenote-toggle sidenote-number">1</label>
  <input type="checkbox" id="sn-1" class="sidenote-toggle-checkbox">
  <span class="sidenote">
    <span class="sidenote-number">1</span>
    The actual footnote content goes here.
  </span>
</span>

The checkbox trick (from Tufte CSS) enables mobile toggling without JavaScript - clicking the label toggles the checkbox, and CSS shows/hides the note based on :checked state.

The CSS

.prose .sidenote {
  float: right;
  clear: right;
  width: 250px;
  margin-right: -280px;
  font-size: 0.875rem;
  color: var(--theme-text-muted);
}

/* Mobile: hide by default, show when toggled */
@media (max-width: 1200px) {
  .prose .sidenote {
    display: none;
    float: none;
    width: 100%;
    margin: 1rem 0;
  }

  .prose .sidenote-toggle-checkbox:checked + .sidenote {
    display: block;
  }
}

The negative margin pulls the sidenote into the right margin. On mobile, we hide it and use the checkbox state to toggle visibility.

Wiring it up

Add the plugin to astro.config.mjs:

import rehypeSidenotes from './src/plugins/rehype-sidenotes.js';

export default defineConfig({
  markdown: {
    rehypePlugins: [rehypeSidenotes]
  }
});

And make sure your layout has room for the margin - I added padding-right: 300px to my content container on wide screens.

What got me

One thing that tripped me up: footnote content often contains <p> tags, but sidenotes are inline elements (inside a paragraph). You can’t nest <p> inside <p>, so the plugin needs to flatten the paragraph wrappers and extract just the inner content.