Standard Markdown footnotes render at the bottom of the page, which means you have to decide whether to scroll down and lose your place or just skip the note entirely. Most footnotes aren’t worth that friction. I’d been living with this on my blog until I read Maggie Appleton’s post about Gastown and noticed her margin notes sitting right next to the text they belonged to, readable without breaking your flow. That felt worth doing. I wanted to keep writing [^1] in Markdown and have the output move the note to the margin automatically, no JavaScript involved.
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 by the time rehype runs, I wrote a plugin that restructures the HTML output.
The plugin does three things:
- Collects all footnote definitions from the
<section data-footnotes>at the bottom - Replaces each footnote reference (
<sup>) with inline sidenote markup - 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
// (implementation omitted)
}
});
// 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, I 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 genuinely tripped me up: footnote content often contains <p> tags, because that’s how remark-gfm wraps the note body. But sidenotes are inline elements, sitting inside a <p> in the main text. You can’t nest <p> inside <p> and get valid HTML. So the plugin has to flatten the paragraph wrappers from the footnote content and extract just the inner nodes. Miss this and your page validates with mysterious extra whitespace and some browsers quietly drop the content.
If you’re building something similar, find me on Bluesky or email keith@keithkurson.net.