Skip to content
Be Useful.

Meandering off the path, shipping minor upgrades

A quick roundup of four reading-experience improvements, shipped while clearing the decks for more ambitious changes. The scope got creeped, but what are ya gonna do?

Pagination and navigation is the next major item on the wish list I published the other week. But before I got there, I wanted to ship a small bundle of improvements to how an individual post can be read. The wrinkle is that only one of them was on that original list.

The back-to-top button was definitely planned. But the table of contents in the sidebar, the image lightbox and code blocks were not. Let's just say I meandered off the path a bit, and picked up some fresh ideas along the way.

Something else: that wish-list post said plainly that I was building with Claude in the chat and not Claude Code or anything fancier. That's no longer the case.

Claude Code became part of the process because it's a faster way to iterate and experiment while a series of ideas kept rattling around in my brain. So consider this post a little changelog of scope creep, but of the best kind.

A quick tour, with a sketch of how each one works, starting with the easiest.

1. Back-to-top button

The one item here I accounted for. A button that appears once you've scrolled down far enough, and drops you back to the top when you tap it. It's mounted once, globally, so every page gets it without any per-page wiring.

The only real decision is motion. Rather than ask you anything, it reads the reduced-motion preference your operating system already exposes, the accessibility setting some people switch on to avoid animation that leaves them queasy. If that's on, the return to the top is instant. If not, it's a smooooooth scroll.

Back-to-top
const prefersReducedMotion = window.matchMedia(
  "(prefers-reduced-motion: reduce)",
).matches;
window.scrollTo({ top: 0, behavior: prefersReducedMotion ? "auto" : "smooth" });

That's the whole thing. A passive scroll listener, a visibility threshold, and one line of respect for a setting you've already made.

2. Floating contents

There's now a table of contents down the side of most posts (or in a collapsible accordion if viewed on mobile), with the section you're reading highlighted as you scroll.

That highlight is an IntersectionObserver with its trigger band deliberately shoved up near the top of the viewport, so the active link tracks the heading sitting just under the sticky header (not whatever happens to be centred on screen).

Table of Contents
const observer = new IntersectionObserver(onIntersect, {
  rootMargin: "-80px 0px -70% 0px",
  threshold: 0,
});

Two things made it more than a list.

  • First, the sidebar needed room, and giving it room meant rethinking the whole post layout into a content-and-sidebar grid on wider screens. That had a knock-on effect. The cover images looked cramped at their old 3:2 ratio inside the new wider layout, so hero and post covers have now moved to 16:9. Mobile and the “more stories” cards kept 3:2, where the taller crop still sits better. This one feature ended up changing the image spec for every page on the site.

  • Second, the links and the headings they point at have to agree exactly, or a contents link lands you nowhere. So the slugs are generated once, in a single place, and both the sidebar and the headings draw from that one source. A link can't point at an anchor that doesn't exist, and a test keeps it that way.

Bonus: The same sidebar also carries an “Explore with AI” widget when viewed on desktop browsers, but despite the name it contains no AI of its own. It's just two buttons that hand the post over to ChatGPT or Claude with a prompt already written.

3. An image lightbox

Clicking an inline image now opens it full size in an overlay.

But making that happen required a bit of coding so the lightbox wouldn't break the experience for anyone reading by keyboard or screen reader. Focus moves into the overlay when it opens, and returns to the exact image you clicked when it closes. Escape and a click on the backdrop both dismiss it.

One neat little detail is the scroll lock. Opening a modal usually freezes the page behind it, and freezing it usually makes the whole page jump sideways as the scrollbar disappears and its width is reclaimed. So before hiding it, the page measures the gap the scrollbar leaves and pads it back.

Image Lightbox
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = "hidden";
if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;

The overlay itself only exists while it's open. Until you click, the image is just an image. Try it out on the picture below.

Is it a fork? Or is it a spoon? YOU DECIDE.

4. Code blocks

The biggest change of the four is being able to show code snippets correctly (and not as the cramped inline snippets the body styling can already handle).

So a code block is now its own content type in the content model. I drop one into a post, give it a language and a filename, paste the code, and it renders as a proper highlighted block.

The one genuinely fiddly part is the highlighting. The library I use, Shiki, is asynchronous, and the function that renders the post body isn't, so you can't highlight a block at the moment you draw it. Before the page renders, every code block in the post is highlighted in one pass and handed to the renderer as finished markup. By the time the body is drawn, there's nothing left to wait for.

Code Block
// Highlighted once, up front. The synchronous renderer just looks each block up.
const highlighted = await highlightCodeBlocks(post.content);

<RichText content={post.content} headings={headings} highlighted={highlighted} />

Every code block in this post, this one included, runs through exactly that. I gave it a dark theme on purpose, so the code stands out against the lightness of the page instead of blending into it.

Plus, each block has a copy button tucked in the corner. It's there because asking you to hand-select code in a browser window is a small cruelty I'd like to spare you from.

What's next?

That's the four. Not one of them is a headline feature, but it's the smaller details which add up to a better experience overall, methinks.

The big thing next on the list is pagination and navigation across the whole archive. Categories, which already exist as attachments to each post, will also play their part.

Excited to share more about the process when it's built and shipped. Onwards!

Desire Path
Editorial illustration, high overhead view looking down on a green park on a sunny afternoon, a neat geometric paved path crossing the grass in straight lines. Cutting diagonally across the lawn, a well-worn bare-earth desire path where people have made their own shortcut, the grass trodden away into a soft brown line. A couple of tiny figures walking the dirt shortcut instead of the pavement. Gouache illustration with visible brushwork, bright palette of greens, ochres and earth browns, soft long shadows, mid-century American editorial style, calm and quietly wry.

Read next

Kinds of blue: Design tokens for contrast and accessibility

I woke up today and decided to change the header colour from crimson to blue. Let's talk about design tokens, contrast and accessibility.

Pay it forward: Fork this blog template and make it yours

The code behind this site is now a template you can clone and deploy for yourself. A sneaky peek at the journey from a public repo to a forkable template.