
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:
.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 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.
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: smoothaside scroll-behavior: autoscroll-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.
The marker is always painted and only ever recoloured, so moving between sections shifts no text:
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.

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:
@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.
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:
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.
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:
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:
dvhrather thanvh. Mobile browser chrome collapses as you scroll, andvhmeasures the tall state, so the card would overhang by exactly the toolbar’s height.--summary-his declared on thedetailsand fed back to thesummaryas itsmin-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 arenowrapand the long one ellipses.overscroll-behavior: containstops 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-behaviordoes not inherit. A globalsmoothonhtmlsays 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-contentis the real flex item inside adetails. 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.