Sidenotes with CSS anchor positioning
Vincent Bernat
I am a heavy user of sidenotes:1 they keep optional content next to the text instead of sending the reader to the bottom of the page and back. Tufte CSS renders them without JavaScript but only accepts inline content. CSS anchor positioning, now supported by recent browsers,2 is an elegant alternative. Sidenotes can hold several blocks, still without JavaScript, and fall back below the paragraph referencing them on narrow viewports and older browsers.
In 2023, Eric Meyer demonstrated this technique in “Nuclear Anchored Sidenotes.” The main improvement over other solutions is that the notes can sit anywhere in the HTML document. You can place them after the paragraph referencing them, as regular block elements for text browsers, screen readers, feed readers, and reader mode to render them properly:

When the viewport is too narrow or the browser does not support CSS anchor positioning, you can style them so the reader can skip them or glance at them without losing their position in the text:

Once the viewport is large enough, they appear in the margin, at the same vertical position as the matching reference mark, unless they would collide with a previous sidenote, as in the example below:3

The gist of CSS anchoring is to position an element relative to another element—the anchor. For the sidenotes, the anchor is the reference mark. I use the following markup, with a data attribute to specify the anchor name:
<sup id="fnref:YYY" data-anchor="--lf-sn-YYY">
<a href="#sidenote-YYY">1</a>
</sup>
The matching note is an <aside> element carrying the same data attribute for
the anchor name. We put it after the paragraph holding the reference mark:
<aside role="note" id="sidenote-YYY" data-anchor="--lf-sn-YYY">
<sup>1</sup>
<p>A first paragraph.</p>
<p>A second paragraph.</p>
</aside>
On a narrow viewport or when the browser is too old for CSS anchoring, we style the sidenote, which stays below its paragraph, with a muted color:
aside[role="note"] {
margin-block: 1rlh;
color: #444;
}
On a wide viewport and when the browser is recent enough, we move the sidenote to the right margin:
@supports (anchor-name: attr(data-anchor type(<custom-ident>))) {
@media (min-width: 72rem) {
main {
position: relative;
sup[data-anchor] {
anchor-name: attr(data-anchor type(<custom-ident>));
/* → anchor-name: --lf-sn-YYY */
}
aside[role="note"][data-anchor] {
anchor-name: --lf-sidenote;
position: absolute;
position-anchor: attr(data-anchor type(<custom-ident>));
/* → position-anchor: --lf-sn-YYY */
top: max(anchor(top), anchor(--lf-sidenote bottom, -1rlh) + 1rlh);
left: 100%;
margin: 0 2rem;
width: 18rem;
color: inherit;
}
}
}
}
attr() extracts the anchor name for the reference mark from the data-anchor
attribute. It returns a string, unless we specify a CSS unit or a type, like
here: the browser parses the data attribute as a custom
identifier, which anchor-name validates as a dashed
identifier, a custom identifier starting with two
dashes.4
The note itself is absolutely positioned past the right edge of the main block.
It selects the matching reference mark as its anchor with position-anchor set
to the value of the data-anchor attribute. Each note is also an anchor named
--lf-sidenote. We use it to keep the next note from colliding with this one.
The anchor() CSS function lets us position the note’s top
edge relative to its anchor: anchor(top) aligns the top edge of the note with
the top edge of the reference mark. It can also take another anchor as a
parameter: anchor(--lf-sidenote bottom) would align the top edge of the note
with the bottom edge of the closest preceding anchor named --lf-sidenote—so
the previous note.5 Like attr(), anchor() accepts a fallback
value as its second parameter and use it when the named anchor does not exist.
The top property handles three cases, illustrated in the following diagram:
- The first note’s top edge aligns with the top edge of its reference mark: as
there is no previous note,
anchor(--lf-sidenote bottom, -1rlh) + 1rlhresolves to 0 andmax()returnsanchor(top). - When the reference mark of a later note sits above the bottom of the previous
note, plus some vertical space, the note goes below the previous one to avoid
a collision.
max()returnsanchor(--lf-sidenote bottom) + 1rlh. - Otherwise, the note’s top edge aligns with the reference mark’s top edge, as
max()returnsanchor(top).
Have a look at the complete stylesheet, which also adapts the reference mark to the location of the note: a “↓” arrow when the note sits below the paragraph, a “→” arrow when it moves to the margin. Gwern’s “Sidenotes In Web Design” lists more implementations and their trade-offs.
Some bloggers aim to write a post in 30 minutes. I planned to publish
three web-related articles this weekend. Instead, I spent an inordinate amount
of time elsewhere: about 15 commits on the build system, a pull request to
update CSS highlighting for nested selectors in Pygments, and a
small correction to MDN’s article on the anchor() CSS function. The
SVG illustration took a bit less than an hour and the article itself a handful
of hours. The attr() function came in after I thought “inline style looks
ugly, isn’t there a better way?” But, hey, I still think this is worth it! 🎨