diff --git a/src/lib/UnstyledSidenote.svelte b/src/lib/UnstyledSidenote.svelte new file mode 100644 index 0000000..50030ca --- /dev/null +++ b/src/lib/UnstyledSidenote.svelte @@ -0,0 +1,48 @@ + + + + + + + + \ No newline at end of file diff --git a/src/routes/_posts/sidenotes.svx b/src/routes/_posts/sidenotes.svx new file mode 100644 index 0000000..914e4a9 --- /dev/null +++ b/src/routes/_posts/sidenotes.svx @@ -0,0 +1,199 @@ +--- +title: Sidenotes +description: An entirely-too-detailed dive into how I implemented sidenotes for this blog. +date: 2023-08-14 +--- + + + + +One of my major goals when building this blog was to have sidenotes. I've always been a fan of sidenotes on the web, because the most comfortable reading width for a column of text is far less than the absurd amounts of screen width we tend to have available, and what else are we going to use it for?Some sites use it for ads, of course, which is yet another example of how advertising ruins everything. + +Footnotes don't really work on the web the way they do on paper, since the web doesn't have page breaks. You _can_ stick your footnotes in a floating box at the bottom of the page, so they're visible at the bottom of the text just like they would be on a printed page, but this sacrifices precious vertical space.On mobile, it's _horizontal_ space that's at a premium, so I do use this approach there. Although I'm a pretty heavy user of sidenotes, so I have to make them toggleable as well or they'd fill up the entire screen. Plus, you usually end up with the notes further away from the point of divergence than they would be as sidenotes anyway. + +I'm also not a huge fan of show-on-hover/click for marginalia, because it requires an extra interaction--and often a fairly precise one, which is always annoying.This is especially true on mobile, where I've found myself selecting text instead of showing/hiding a note because I didn't get my finger in quite the right place. Admittedly this style _does_ get you the absolute minimum distance between the marginalia and the main content, but I think the extra interaction is too heavy a price to pay.Except on mobile, as mentioned. Mobile displays just don't have _any_ extra space at all, so you're left choosing between various unappealing options. + +So we're left with sidenotes, which I consider the crème de la crème of web-based marginalia. So okay, sure, sidenotes are great and all, but how do we actually _do_ them? Well! _wipes imaginary sweat from brow_ It sure was tough, and for a while there I thought I'd never make it through, but I done did figgered it out in the end!_Narrator:_ He had not figured it out. He had googled around until he found someone else who had figured it out, and then copied their solution. + +## The Suboptimal Solution: Absolute Positioning + +I'm naturally lazy, so I wanted the authoring experience to be as low-friction as possible so that I wouldn't end up foregoing sidenotes just because they were too much of a pain to put in. Since I had already settled on [mdsvex](https://mdsvex.pngwn.io/docs) for authoring my posts, I wanted sidenotes to be just another component that I could throw in mid-stream whenever I had an ancillary thought to express. This meant that DOM-wise, the sidenotes were going to be mixed right in with the main body text. Since I was also hoping to do this in pure CSS,Because as much as I claim not to care, I secretly fear the Hacker News anti-Javascript brigade and desperately crave their approval. meant that I was going to have to do something that removed the sidenote from the normal document flow, such as `position: absolute`. + +My first approach was something like this: + +```css +.sidenote { + position: absolute; + /* 50% takes us to the midpoint of the page, + half of content-width gets out out to the gutter, + and the extra 1rem gives us some breathing room. */ + left: calc(50% + var(--content-width) / 2 + 1rem); + max-width: 12rem; + font-size: 0.75rem; +} +``` + +And it worked! Sort of. Here's an example.My initial take on sidenotes. Seems to be working, right? Unfortunately it has a major flaw: Absolute positioning removes an element from the document flow _entirely_, while I wanted sidenotes to still flow with _each other_, That doesn't happen with this solution--if you have multiple sidenotes too close together, they will overlap because absolute positioning Just Doesn't Care.Like this one.And this one, which I've moved down just a smidge to make the overlap more apparent. + +Obviously, it isn't that hard to just scan through the page looking for sidenotes, detect when they overlap, and then (since they're already absolutely positioned) adjust their `top` values appropriately to get rid of the overlap. But I didn't want to do this for a variety of reasons. + +* I wanted to write this as a Svelte component, which means that's the obvious place to put this logic. But because there are many instances of the component and I only want to run the collision-detection logic once, it has to be coordinated across multiple instances of the same component, which is painful. +* Because we have to wait for the sidenote elements to _have_ concrete positions before we can detect whether they collide, we can't do this until they are mounted (i.e. inserted into the DOM). I was concerned that this would cause [FOUC](https://en.wikipedia.org/wiki/Flash_of_unstyled_content)-like problems, although in retrospect I don't actually recall it happening.Possibly it was mitigated by the way Svelte batches DOM updates.However, since I was always planning on static-rendering the site and letting SvelteKit do client-side hydration on page load, I don't think the possibility could ever be ruled out entirely. +* Anything that triggered a reflow could cause the text to move around, but the sidenotes might not follow suit.Specifically: sidenotes that had been adjusted to get rid of overlap would stay where they were, because they would already have an explicit `top` property. Sidenotes that hadn't been adjusted would move up and down as text reflowed, but this meant they could end up overlapping again. [There are a lot of things that can cause a reflow](https://gist.github.com/paulirish/5d52fb081b3570c81e3a),And this is just the ones that come from Javascript! It doesn't even address stuff like resizing the window or expanding/collapsing a `
` element. and I'd have to listen to all of them if I wanted this to be a fully general solution. Sure, I could just be aware of this problem and avoid using reflow-causing events where possible--but I wanted the freedom to be able to add as much interactivity as I felt like to any given blog post without having to worry. + +None of these problems are _completely_ inaddressible, but it was all going to be very fiddly to fix properly, so I decided to do a bit more research before throwing in the towel. And boy am I glad that I did, because it turns out that with enough... + +## CSS Wizardry + +...anything is possible. + +Eventually I ran across [this post](https://scripter.co/sidenotes-using-only-css/), which solved my problem almost perfectly. The basic idea is extremely straightforward: + +1. Give your sidenotes a `float` and `clear` in the same direction, so that they are removed from the regular document flow _but_ (and this is crucual) _they will still take each other into account for layout purposes._ +2. Give them a fixed width, and then: +3. Give them a negative margin equal to the max-width, so that they are pulled out of the body of the text and hang out in the gutter. + +It's shockingly simple, to be honest--I would never have thought of it myself, but I'm glad somebody out there did.It's worth noting that this same approach seems to be used by [Tufte CSS](https://edwardtufte.github.io/tufte-css/), which I had looked at previously but had failed to comprehend, possibly because it doesn't really go into detail about its sidenote mechanism. The only problem is that you can't nest sidenotes, which is something I had hoped to support, but we'll get to that in a bit. + +## Implementation + +It took me quite a while (longer than it should have, probably) to really grok this, so I wanted to go through the implementation step-by-step and show the effect of each component part. For starters, let's just get the basic appearance out of the way: + +```css +body { + counter-reset: sidenote; +} + +.counter { + counter-increment: sidenote; + margin-left: 0.05rem; +} +.counter::after { + content: counter(unstyled-sidenote); + font-size: 0.75em; + position: relative; + bottom: 0.3em; + color: var(--accent-color); +} + +.sidenote { + color: var(--content-color-faded); + font-size: 0.8rem; +} +.sidenote::before { + content: counter(unstyled-sidenote); + font-size: 0.75rem; + color: var(--accent-color); + /* Since the sidenote is floated it counts as a positioned element, + so this would make the counter overlap the start of the text... */ + position: absolute; + /* ...except that we move it out to the left and up a bit, so + it's hanging out in space. 100% refers to the width of this + pseudo-element, so we handle different-sized counters the same. */ + transform: translate( + calc(-100% - 0.16em), + -0.12em + ); +} +``` + +This handles font size, color, and counters--CSS counters are very convenient for this, because they automatically adjust themselves whenever I go back and add or remove a sidenote earlier in the page. That gives us sidenote that looks like this:We're going to use a different color counter for these ones, so they can be more easily distinguished. + +It's still in flow, so our first change will be to remove it from the standard flow with `float: right`. Doing that moves it over to the side, like so.The float also unmoors it from the text baseline. Notice how it still takes up space in the body text, even though it's happening in a different place than its DOM location. + +To keep it from doing that, we'll add a combination of a fixed width and a negative margin. The fixed width is primarily to give us a "target" number for the negative margin, since there isn't a good way to reference the width of the _current_ item when defining margins. (`margin-right: 100%` would move it by the width of the _containing_ block, which is not what we want.) With that in place, here's what we get.Looking pretty good! Unfortunately this example and subsequent ones don't work on mobile, since there are no gutters. Sorry about that! You'll have to view the desktop version to make them show up. + +The next step is to keep the sidenotes from overlapping when there are multiple of them in quick succession, like these two.This is one sidenote.Another sidenote, which overlaps the first. We do that with the `clear` property, which, when applied to a floated element, causes it to drop below any preceding floated elements on the specified side with which it would otherwise share a line. + +This is easiest to show with an example, so let's do that. Here are two sidenotes with just `float: right` and no negative margin.One.Two. [[Click here]] to animate the negative margin being applied to first the one, then the other. Applying negative margin to the first sidenote creates space for the other one to move to the side, since by nature floats want to form a horizontal row against the side of their containing block. Once we start applying negative margin to the second sidenote, though, normal flow rules don't apply, and they start to overlap. + +This is fixed by `clear` because it changes the behavior of floats. Here are the same two sidenotes as above, but with `clear: right` applied to the second.One.Two. The `clear` property causes the second sidenote to drop below the first, which happens to be exactly the behavior that we want. All that's left is to apply the negative margin like soThree.Four.and the whole stack will slide right over into the gutter. + +It's smack up against the body text, though. In fact, since the floating counter hangs off to the left, it actually overlaps with the body text.(Depending on line wrapping, this may not be immediately apparent from the above.) + +We can fix that in one of two ways. 1) We can increase the negative margin so that it's _greater_ than the width of the sidenote, or 2) We can just stick in some padding.Voila! Collision avoided. I like the first option better, because it better reflects what we're actually doing here--margin is for creating caps _outside_ and _between_ elements, while padding is for gaps _inside_. + +Here's what we have so far: + +```css +.sidenote { + float: right; + width: 14rem; + margin-right: -16rem; +} +``` + +We still have a bit of a problem, though. Because we've assigned the sidenote a fixed width, it doesn't automatically shrink when the window gets too small for it. Obviously, of course, at _some_ point we're going to switch to the mobile version, which displays at the bottom of the screen and can be toggled on or off. But there are plenty of widths where sidenotes would still work perfectly well, just with a slightly narrower width than our initial `14rem`. + +Fortunately, CSS `calc()` is widely supported and does exactly what we need.Here we are! You may need to resize your window to get full effect. Let's take a look: + +```css +.sidenote { + float: right; + --width: min( + 14rem, + calc( (100vw - var(--content-width) ) / 2 - 2rem ) + ); + width: var(--width); + margin-right: calc(0rem - var(--width) - 2rem); +} +``` + +To calculate the width, we take the full viewport (`100vw`) and subtract the width of the main column (`var(--content-width)`). This gives us the combined width of both gutters, but since we only want the width of a single gutter we divide by 2. Then we subtract a further `2rem` so that our width is a little less than the full width of ther gutter, to give us some breathing room. + +For the margin, we just take the value we calculated for the width and subtract it from 0 (to make it negative), then subtract a further 2rem to pull the sidenote out by that much more to give us breathing room.