Be Useful.
Code
Published

Meandering off the Path

Bulent Yusuf
Bulent Yusuf
Cover Image for Meandering off the Path

A quick changelog of four improvements to how a single post reads, shipped while clearing the decks for more ambitious changes.

Introduction

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 first.

1. Back-to-top button

The one item here I actually planned. 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 list down the side of most posts, 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 centered 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 bit of 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. One small feature quietly reset an image spec across the whole 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, which despite the name 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.

Most of the work here is invisible, and trying not to 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 to look up. 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!


Prompt for key visual

Midjourney: "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."


More Stories

Cover Image for When the prompt becomes a creative brief

When the prompt becomes a creative brief

Writing a prompt that yields a specific image is closer to art direction than to drawing. But there are legal and ethical considerations, too.

Cover Image for Publish a blog post without opening the CMS?

Publish a blog post without opening the CMS?

Behold! The future! A Model Context Protocol server is a newish AI standard that can make a content platform addressable through a chat interface.