Sidenotes with CSS anchor positioning

Vincent Bernat

I am a heavy user of side­notes:1 they keep op­tional con­tent next to the text in­stead of send­ing the reader to the bot­tom of the page and back. Tufte CSS ren­ders them with­out Java­Script but only ac­cepts in­line con­tent. CSS an­chor po­si­tion­ing, now sup­ported by re­cent browsers,2 is an el­e­gant al­ter­na­tive. Side­notes can hold sev­eral blocks, still with­out Java­Script, and fall back be­low the para­graph ref­er­enc­ing them on nar­row view­ports and older browsers.

In 2023, Eric Meyer demon­strated this tech­nique in “Nu­clear An­chored Side­notes.” The main im­prove­ment over other so­lu­tions is that the notes can sit any­where in the HTML doc­u­ment. You can place them af­ter the para­graph ref­er­enc­ing them, as reg­u­lar block el­e­ments for text browsers, screen read­ers, feed read­ers, and reader mode to ren­der them properly:

Sidenotes rendered in Lynx appear after the paragraph they are called
from.
Rendering in Lynx, a text browser

When the view­port is too nar­row or the browser does not sup­port CSS an­chor po­si­tion­ing, you can style them so the reader can skip them or glance at them with­out los­ing their po­si­tion in the text:

Sidenotes rendered on a narrow viewport appear with a distinctive typography
after the paragraph they are called
from.
Rendering below the paragraph on a narrow viewport

Once the view­port is large enough, they ap­pear in the mar­gin, at the same ver­ti­cal po­si­tion as the match­ing ref­er­ence mark, un­less they would col­lide with a pre­vi­ous side­note, as in the ex­am­ple below:3

Sidenotes rendered on a large viewport appear in the margin. There are two of
them. The first one is vertically aligned with the matching reference mark,
while the second is rendered below as it would collide with the first
otherwise.
Rendering in the margin on a large viewport

The gist of CSS an­chor­ing is to po­si­tion an el­e­ment rel­a­tive to an­other el­e­ment—the an­chor. For the side­notes, the an­chor is the ref­er­ence mark. I use the fol­low­ing markup, with a data at­tribute to spec­ify the an­chor name:

<sup id="fnref:YYY" data-anchor="--lf-sn-YYY">
  <a href="#sidenote-YYY">1</a>
</sup>

The match­ing note is an <aside> el­e­ment car­ry­ing the same data at­tribute for the an­chor name. We put it af­ter the para­graph hold­ing the ref­er­ence 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 nar­row view­port or when the browser is too old for CSS an­chor­ing, we style the side­note, which stays be­low its para­graph, with a muted color:

aside[role="note"] {
  margin-block: 1rlh;
  color: #444;
}

On a wide view­port and when the browser is re­cent enough, we move the side­note 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() ex­tracts the an­chor name for the ref­er­ence mark from the data-anchor at­tribute. It re­turns a string, un­less we spec­ify a CSS unit or a type, like here: the browser parses the data at­tribute as a cus­tom iden­ti­fier, which anchor-name val­i­dates as a dashed iden­ti­fier, a cus­tom iden­ti­fier start­ing with two dashes.4

The note it­self is ab­so­lutely po­si­tioned past the right edge of the main block. It se­lects the match­ing ref­er­ence mark as its an­chor with position-anchor set to the value of the data-anchor at­tribute. Each note is also an an­chor named --lf-sidenote. We use it to keep the next note from col­lid­ing with this one.

The anchor() CSS func­tion lets us po­si­tion the note’s top edge rel­a­tive to its an­chor: anchor(top) aligns the top edge of the note with the top edge of the ref­er­ence mark. It can also take an­other an­chor as a pa­ram­e­ter: anchor(--lf-sidenote bottom) would align the top edge of the note with the bot­tom edge of the clos­est pre­ced­ing an­chor named --lf-sidenote—so the pre­vi­ous note.5 Like attr(), anchor() ac­cepts a fall­back value as its sec­ond pa­ram­e­ter and use it when the named an­chor does not exist.

The top prop­erty han­dles three cases, il­lus­trated in the fol­low­ing diagram:

Diagram of three sidenotes anchored to their reference marks. The first one is
aligned with the top of its own reference mark, as no note comes before it. The
second one would overlap the first, so it takes the bottom of the first note as
anchor and sits one line below it. The third one comes far enough down the page
to align with its own reference mark
again.
The three cases for the vertical position of a note
  1. The first note’s top edge aligns with the top edge of its ref­er­ence mark: as there is no pre­vi­ous note, anchor(--lf-sidenote bottom, -1rlh) + 1rlh re­solves to 0 and max() re­turns anchor(top).
  2. When the ref­er­ence mark of a later note sits above the bot­tom of the pre­vi­ous note, plus some ver­ti­cal space, the note goes be­low the pre­vi­ous one to avoid a col­li­sion. max() re­turns anchor(--lf-sidenote bottom) + 1rlh.
  3. Oth­er­wise, the note’s top edge aligns with the ref­er­ence mark’s top edge, as max() re­turns anchor(top).

Have a look at the com­plete stylesheet, which also adapts the ref­er­ence mark to the lo­ca­tion of the note: a “↓” ar­row when the note sits be­low the para­graph, a “→” ar­row when it moves to the mar­gin. Gw­ern’s “Side­notes In Web De­sign” lists more im­ple­men­ta­tions and their trade-offs.

Some blog­gers aim to write a post in 30 min­utes. I planned to pub­lish three web-re­lated ar­ti­cles this week­end. In­stead, I spent an in­or­di­nate amount of time else­where: about 15 com­mits on the build sys­tem, a pull re­quest to up­date CSS high­light­ing for nested se­lec­tors in Pyg­ments, and a small cor­rec­tion to MDN’s ar­ti­cle on the anchor() CSS func­tion. The SVG il­lus­tra­tion took a bit less than an hour and the ar­ti­cle it­self a hand­ful of hours. The attr() func­tion came in af­ter I thought “in­line style looks ugly, isn’t there a bet­ter way?” But, hey, I still think this is worth it! 🎨