Tags
The Astro rocket logo with a pink flame and the astro wordmark in white on a black card

An Astro table of contents that keeps your place — scroll-spy, a shared rail, and dvh on mobile

Follow-the-reader scrolling, a shared rail, and the ::details-content box that silently ate a mobile max-height in an Astro table of contents.

On this page

Introduction

I was reading one of my own articles, got a dozen sections deep, and glanced at the table of contents to see where I was. It was showing the top of the article. The one component on the page whose entire job is telling you where you are had quietly stopped doing it, and I had not noticed because I never read my own posts end to end.

My first guess was that the list had grown too long and was overflowing. It was not overflowing. It had been scrollable for as long as it had existed. What it never did was scroll itself, so the highlight marking my position sat somewhere outside the visible part of a column that looked, from where I was sitting, dead.

This article walks through what a table of contents actually needs in order to keep a reader oriented: following the reader without hijacking the page, marking the active entry so the mark survives without colour, and capping the phone version so it cannot run off the screen. Two of those turned out to have causes that were nothing like what they looked like.

What keeping your place requires

Four requirements, and only the first is obvious.

Requirement Why it is not automatic
The active entry stays visible Scroll-spy sets a class. Nothing scrolls the list to reveal it.
The mark survives without colour A colour swap is invisible to a reader who cannot use colour, and is quiet even to one who can.
The list fits the screen it is on A sticky card with no height limit grows past the viewport.
Nothing competes for the column Vertical space in a sidebar is the scarce resource, and a heading spends it permanently.

The site met none of them, and I had assumed it met the first three.

The sidebar was already scrolling

The desktop table of contents lives in an aside next to the article. It carried both halves of a scrollable box already:

src/layouts/BaseLayout.astro
.page[data-has-toc] .toc {
position: sticky;
top: var(--nav-height);
max-height: calc(100vh - var(--nav-height));
overflow-y: auto;
}

So the column scrolled. What was missing was anything that moved it. A search of the source for the two APIs that could have turned up no scrollIntoView and no scrollTop assignment anywhere in src/. The scroll-spy resolved the active heading, set aria-current="true" on the right link, and stopped there.

That was the bug. A scroll-spy that only marks works perfectly and helps nobody, because on any list short enough to fit the viewport it is indistinguishable from one that also scrolls, and every list long enough to need the help is one where the mark has gone off-screen.

Following the reader without moving the article

The agent proposed the fix and I took it: move the sidebar only when the active entry has left a band inset from the top and bottom of the scrollable box.

The sidebar only scrolls when the active entry leaves the bandThree copies of the same tall table of contents column. A band is marked inside each one, inset 24 pixels from the top edge and 48 pixels from the bottom. In the first column the active entry sits inside the band and nothing moves. In the second it sits above the band, and an arrow shows it returning to the band top edge. In the third it sits below the band, and an arrow shows it returning to the bottom edge. In both moving cases the column scrolls only far enough to reach the nearest edge.The table of contents column, scrolled on its ownEntry inside the bandactive entryno movementEntry above the band topactive entrymoves back to the top edgeEntry below the band bottomactive entrymoves back to the bottom edge24px48pxbandThe column only moves when the entry leaves the band
The active entry is only chased when it leaves the band. Inside it, the column stays where the reader put it.

The alternative was re-centring the active entry on every change. That is more predictable and it never stops moving, which turns a navigation aid into a fidget. Nearest-edge leaves the column still while a long section is being read, and returning zero while the entry is in-band is what stops the feature fighting a reader who scrolled the list by hand.

src/components/TableOfContents.astro
const top = port.top + 24;
const bottom = port.bottom - 48;
const above = box.top - top;
const below = box.bottom - bottom;
const delta = above < 0 ? above : below > 0 ? below : 0;
if (delta) scroller.scrollBy({ top: delta, behavior });

Two platform details shaped that code more than the design did.

scrollIntoView was unusable. The obvious call is link.scrollIntoView({ block: 'nearest' }), and it walks every scrollable ancestor on the way up, the document included. Calling it would have scrolled the article out from under the reader in order to tidy the sidebar. Driving the one element’s own scrollBy is the only way to guarantee the page does not move.

The behavior argument had to be passed, and the reason was the opposite of what went into the comment. The agent wrote that the global scroll-behavior: smooth on html would make the sidebar animate whether or not we asked, so the argument existed to force instant scrolling under prefers-reduced-motion. A reviewer flagged it, and checking in the browser settled it in one line:

html scroll-behavior: smooth
aside scroll-behavior: auto

scroll-behavior is not an inherited property. The rule on html never reached the aside’s scrolling box, which computes the initial auto. Passing behavior explicitly is still required, but because the default would jump, not because it would animate. The code was right by accident and the recorded reason was backwards, which is the more expensive of the two to leave in place.

One rail for every heading depth

The active entry had been marked with a colour change and nothing else. Colour alone is both a WCAG 1.4.1 problem and, in a twenty-entry list, simply too quiet to find. I asked for a thicker left border. The agent came back recommending a continuous rail with a thicker active segment instead, on the grounds that a border which only exists when active shifts every other entry by its width.

The interesting part is where the rail goes. Indentation in this list is encoded as padding-inline-start, and nested lists carry no padding of their own, so every anchor’s border-box start edge lands at the same x whatever its depth. A marker pinned to that edge is shared by parents and children automatically. A border-inline-start on the anchors, which is the first thing to reach for, stair-steps the rail once per level instead.

Padding-based indentation keeps one rail for every heading depthTwo columns showing the same table of contents list: two h2 entries, each followed by two indented h3 entries. On the left, indentation comes from padding, so one continuous vertical rail runs at a single x to the left of all six entries, with a thicker accent-coloured segment marking one indented h3 entry. On the right, a border on each anchor puts the rail at one x for the h2 entries and at a deeper x for the indented h3 entries, so the marker breaks into stair-stepped segments instead of a single line.One rail beside the listPadding indents the text, not the boxactiveone rail, any depthA border on each anchorThe border rides the indented boxactiveone rail per level
Indentation lives in padding, so one rail serves both heading levels. Putting the border on the anchors would produce one rail per level.

The marker is always painted and only ever recoloured, so moving between sections shifts no text:

src/components/TocList.astro
a::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: -2px;
inline-size: 3px;
background: transparent;
}
a[aria-current='true']::before {
background: var(--toc-marker, transparent);
}

It thickens inward, over the anchor’s own padding, rather than outward. Growing outward would overhang the scrollport, and overflow-y: auto computes overflow-x to auto as well, so the overhang would have bought a horizontal scrollbar for one pixel of rail.

The sidebar table of contents, showing a continuous vertical rail beside the list with a thicker blue segment marking the active entry, and the list fading out at the bottom edge

The result: one rail, a thickened accent segment on the current section, and a fade at each end where the list continues.

There is a second colour trap underneath the first one. In forced-colors mode the marker’s background is overridden to Canvas and every link’s color collapses to LinkText, so the non-colour signal built to replace a colour signal disappears for exactly the readers it was built for. The fix is the pattern the reading-progress bar in this repo already uses:

src/components/TocList.astro
@media (forced-colors: active) {
a[aria-current='true']::before {
background: Highlight;
forced-color-adjust: none;
}
}

The sticky heading cost 10.3% of the column

This is the part I got wrong, and the number in the heading above is what it cost.

I asked for the “On this page” heading to be made sticky, so it would stay visible as the list scrolled. The agent recommended against it and I overrode that, which was a reasonable call to make on an unmeasured claim. Then it was built and measured.

A pinned heading spends a tenth of the sidebar columnTwo vertical bars drawn at one scale, each standing for the same 671 pixel sidebar column. The left bar, with a sticky heading, gives the top 68.8 pixels — 10.3 percent of the column — to a pinned "On this page" band, which leaves a 546 pixel band for the active entry. The right bar has no heading band, so the same usable band measures 599 pixels. Both bands end at the same bottom inset, so the whole 53 pixel difference appears as a highlighted slice at the top of the right bar.With a sticky headingWithout itpinned "On this page" heading68.8px10.3% of the columnusable band for the active entry546px671px column+53pxmore room for the bandusable band for the active entry599px671px columnThe pinned heading spends 10.3% of the column,taken from the same budget the follow-the-reader band uses.
Measured on the live page. The pinned heading consumes a tenth of the column, and every pixel of it comes out of the band the reveal has to work with.

On a 671px column the pinned heading was 68.8px, or 10.3% of the column, permanently. Worse, it does not merely take space, it takes it from the same budget the previous section spends: the band an active entry is allowed to sit in shrank from 599px to 546px, so the reveal fired sooner and more often. The feature I had asked for and the one I had just approved were pulling against each other. The cost also scales the wrong way, because a shorter laptop viewport shrinks the column while 68.8px stays 68.8px.

I removed it. The label was self-evidently a caption for a list of article headings, and no reader was ever going to lose track of what that column was.

One thing the opaque heading had been quietly providing did have to be replaced. The reading-progress bar sits inside the site header at bottom: -1px with a height of 3px, so it overhangs the sidebar’s first two pixels and had been slicing the tops of entries as they scrolled past. A mask gradient handles that without spending any height at all:

src/layouts/BaseLayout.astro
mask-image: linear-gradient(
to bottom,
transparent,
#000 1rem,
#000 calc(100% - 2rem),
transparent
);

That gradient then created a defect of its own, caught in review. Sequential focus navigation scrolls a focused element only just into view, which parks it inside a ramp and renders its focus ring at partial alpha. scroll-padding-block: 1rem 2rem on the same element makes the browser’s own focus scroll land clear of both fades.

::details-content ate the mobile max-height

Below 72rem the table of contents is a details card that sticks under the header. It had no height limit at all, so opening it on a phone grew a list that ran off the bottom of the screen with no way back to it. I asked for it to be capped.

The first attempt was the tidy one. Make the details a flex column with a max-block-size, and let the nav inside take whatever the summary leaves:

details {
display: flex;
flex-direction: column;
max-block-size: calc(100dvh - var(--nav-height) - 2rem);
}
details nav {
min-block-size: 0;
overflow-y: auto;
}

Measured in the browser, the card was capped correctly at 628px. The nav inside it came back 1057px tall, with scrollHeight - clientHeight equal to 0. It was overflowing, not scrolling, and every declaration in that snippet was applying exactly as written.

The cause is a box that does not appear in the markup.

The real flex item inside a details element is ::details-contentA box tree. The outer box is a details element set to display flex with a column direction. Its two flex items are drawn as direct children: a summary box, and a dashed ::details-content box marked as generated rather than written in the markup, computing to display block. The nav element sits nested inside that generated box, one level too deep to be a flex item. Two annotations explain that min-block-size zero on the nav is valid CSS applied to the wrong box, so the generated box never shrinks and the nav has no bounded parent to scroll inside, and that a pseudo-element cannot be reached from script, so the scrollport has to be the real nav element.detailsdisplay: flex; flex-direction: columnIts two flex itemssummaryflex item::details-contentgenerated, not in your markupdisplay: blockflex itemnavnot a flex item — one level too deepRight CSS, wrong boxmin-block-size: 0 is set on the nav,and it is valid CSS that applies.But the nav is not the flex item, sothe generated box never shrinks andkeeps its full content height.Why the cap went on the navA pseudo-element cannot be reachedfrom script, so the scrollport has tobe a real element.CSS aimed at the nav applies cleanly and does nothing.The nav has no bounded parent, so it overflows instead of scrolling.
Slotted content is wrapped in a generated box. The nav is a grandchild, so flex properties aimed at it apply and do nothing.

Browsers wrap a details element’s slotted content in a ::details-content pseudo-element, and that box, not the nav, is the real flex item. So min-block-size: 0 on the nav was correct CSS applied to the wrong box: the generated box never shrank, so the nav had no bounded parent to scroll inside. Chrome 151 reports it as display: block with a computed height of 0px.

Moving the cap to ::details-content would have worked and was still wrong, because a pseudo-element cannot be reached from script, and the card needs to be scrolled to the active entry when it opens. The cap therefore went on the nav itself, which keeps it both the scrollport and something querySelector can return:

src/components/TableOfContents.astro
details nav {
max-block-size: calc(100dvh - var(--nav-height) - var(--summary-h) - 2rem);
overflow-y: auto;
overscroll-behavior: contain;
}

Three details in that one declaration:

  • dvh rather than vh. Mobile browser chrome collapses as you scroll, and vh measures the tall state, so the card would overhang by exactly the toolbar’s height.
  • --summary-h is declared on the details and fed back to the summary as its min-block-size, so the number the cap subtracts and the strip’s real height cannot drift apart. The summary is single-line by construction, since both of its spans are nowrap and the long one ellipses.
  • overscroll-behavior: contain stops a flick at the end of the list from chaining into the article underneath.

Capping the card recreates the problem the desktop column had, so the card catches up to the active entry on toggle rather than on scroll. An open card means the reader is choosing a destination, not reading along, and following them while it is open would be noise.

What the numbers said

Verifying scroll behaviour turned out to have two traps of its own, and each produced a confidently wrong answer before it was caught.

A Playwright element screenshot scrolls the thing it measures. Calling locator('aside.toc').screenshot() runs scrollIntoViewIfNeeded() first, which scrolled the sidebar’s own scrollport. The resulting picture showed a column that had apparently followed the reader beautifully while its scrollTop was still 0.

The global smooth scrolling means the page has not arrived when you measure. A sweep requesting 4000, 9000 and 14000 actually read 3677, 8577 and 13568, and every conclusion drawn from it was wrong. Driving with window.scrollTo({ behavior: 'instant' }) and settling for 1.8s fixed it.

With that corrected, the settled positions look like this. The two gap columns are the distance from the active entry to each edge of the band, so a negative number in either would mean the reveal had failed:

Page offset Active entry Sidebar scrollTop Gap above Gap below
0 Introduction 0 45 692
4000 Cacheable 0 243 329
9000 Routing the QUERY method 0 450 122
14000 Python client (httpx) 246 572 0
19000 The real axis of competition 605 572 0

The first three rows are the feature declining to act. The entry is inside the band, so the sidebar does not move, which is the behaviour that keeps it from feeling twitchy. Nudging the sidebar by hand without changing section returned it to exactly where it was put, and window.scrollY never changed by a pixel across any of it.

On a 390x760 viewport the card measures 630px against a 760px screen, the list inside it scrolls by 473px, and flicking it to the end moves the article by 0.

Summary

The component that tells you where you are had stopped telling me where I was, and almost nothing about the repair was where I expected to find it.

  • The list was never overflowing. It had been scrollable all along and simply had nothing to scroll it, so the fix was a missing behaviour rather than a missing height.
  • Nearest-edge beats re-centring. Moving only when the active entry leaves a band is what lets the column stay still while a section is read, and what stops it overriding a reader who scrolled it by hand.
  • scroll-behavior does not inherit. A global smooth on html says nothing about any other scrolling box, and a comment asserting otherwise survives longer than the bug would have.
  • A sticky heading cost 10.3% of the column, taken from the same budget the follow-the-reader band spends. I asked for it, the measurement came back, and I took it out.
  • ::details-content is the real flex item inside a details. CSS aimed at the element you wrote will apply cleanly and do nothing, and because a pseudo-element is unreachable from script, the scrollport has to be a real element anyway.

Measuring is what did the work here. Three of these were invisible until something was put on a scale, and one of them was a feature I had argued for.

References

Share this article