Small UX issues compound. Individually, a misaligned sidebar or a heading hidden behind a navbar is a minor annoyance. Together, they make a blog feel unfinished. After the responsive layout overhaul, the big scaling issues were fixed — but three polish items remained on the blog post pages.
The table of contents component had two layouts: a sidebar on the right for wider screens, and a collapsed section above the article for narrow screens. The switch happened at 769px (the tablet breakpoint).
The issue was that at 769-1023px, there simply was not enough horizontal space for both the article and the sidebar. The article card got squeezed to an uncomfortably narrow width, and the TOC sidebar felt cramped. The content was fighting for space in a viewport that could not support both.
Move the breakpoint from 769px to 1024px. At 769-1023px, the TOC now stays above the article in a full-width layout — same as mobile. Only at 1024px and above, where there is genuinely enough room, does it move to the sidebar position.
/* Before — too early */
@media (min-width: 769px) {
.post__container { flex-direction: row; }
.toc { flex: 0 0 clamp(180px, 22vw, 280px); }
}
/* After — enough room */
@media (min-width: 1024px) {
.post__container { flex-direction: row; gap: 5%; }
.toc { flex: 0 0 20%; width: 20%; }
}
At 769px, the TOC simply stays on top with order: -1 and width: 100%. The article gets full width. No cramping, no compromise.
The site has a fixed navbar that sits at the top of the viewport (90px tall). When you clicked a heading in the table of contents, scrollIntoView({ behavior: 'smooth' }) would scroll the heading to the very top of the viewport — right behind the navbar. You had to manually scroll up to actually see the heading you just clicked.
Two changes working together:
CSS scroll-padding tells the browser to account for the navbar when calculating scroll positions:
html {
scroll-behavior: smooth;
scroll-padding-top: calc(var(--navbar-height) + 1rem); /* 90px + 16px */
}
JavaScript scroll calculation in the TableOfContents component replaces scrollIntoView with a manual offset calculation, because scrollIntoView does not reliably respect scroll-padding-top when called programmatically:
onClick={(e) => {
e.preventDefault();
const el = document.getElementById(heading.id);
if (el) {
const navbarHeight = 90;
const offset = 16;
const top = el.getBoundingClientRect().top + window.scrollY - navbarHeight - offset;
window.scrollTo({ top, behavior: 'smooth' });
}
}}
Now clicking a TOC link scrolls the heading to a comfortable position just below the navbar, with 16px of breathing room.
The TOC highlights the current section as you scroll through the post. This "scroll-spy" behaviour uses the IntersectionObserver API — it watches all heading elements and reports when they enter or leave the viewport.
The problem was that the observer's rootMargin did not account for the navbar. It considered a heading "in view" when it crossed the top of the viewport — but the top 90px of the viewport is behind the navbar. So the active TOC link would update too late, after the heading had already scrolled behind the navbar.
Adjust the rootMargin to ignore the navbar zone:
const observer = new IntersectionObserver(callback, {
rootMargin: '-106px 0px -80% 0px'
});
The -106px top margin (90px navbar + 16px padding) means the observer ignores the top 106px of the viewport when determining which heading is visible. The -80% bottom margin means only the top 20% of the visible area triggers updates, keeping the active link ahead of the reader's position.
These three fixes work together. The TOC sidebar appears at the right breakpoint (1024px+). Clicking a heading scrolls to the right position (below the navbar). The active highlight tracks the right heading (accounting for the navbar zone).
None of these changes are individually dramatic. But together, they transform the blog reading experience from "mostly works" to "just works." The reader never has to think about the navigation — it does what they expect, every time.
Fixed elements break scroll assumptions. The moment you add a fixed navbar, every scroll-related feature needs to account for it — scroll-spy, anchor links, sticky elements, scroll-padding. It is easy to forget one of these and create a subtle UX bug.
IntersectionObserver's rootMargin is powerful. It effectively lets you define a "virtual viewport" that differs from the actual viewport. For sites with fixed headers or footers, this is essential for correct scroll tracking.
Test at every breakpoint, not just mobile and desktop. The TOC sidebar issue only appeared at 769-1023px — a range that is easy to skip when testing. A slow resize from 1920px down to 320px would have caught it immediately.
With the reading experience polished, the next feature on the list was media management. Blog posts needed images and PDFs, but there was no way to upload them through the admin UI. The content was in S3, but the upload path was manual.
That story is in Building a Media Upload Pipeline with S3 Presigned URLs and CDN Delivery.
This post is part of a series documenting the development of arkadiuszkulpa.co.uk.