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

- Source: https://oharu121.com/blog/astro-table-of-contents-scroll-spy-shared-rail-details-content/
- Published: 2026-08-13T10:03:19+09:00
- Tags: Astro, CSS

---
## 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:

```css title="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.

*Figure — RevealBand: 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.**

```ts title="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:

```text
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.

*Figure — SharedRail: 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:

```css title="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.

*Figure: 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:

```css title="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.

*Figure — StickyCost: 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:

```css title="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:

```css
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.

*Figure — DetailsBoxTree: 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:

```css title="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

- [CSSOM View Module: `scroll-behavior`, including the line saying it is not inherited](https://drafts.csswg.org/cssom-view/#propdef-scroll-behavior)
- [HTML Standard: the `details` element and its slotted content box](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-details-element)
- [MDN: `::details-content`](https://developer.mozilla.org/en-US/docs/Web/CSS/::details-content)
- [WCAG 2.2: Use of Color (1.4.1)](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html)
- [WCAG 2.2: Focus Not Obscured (2.4.11)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html)
- [MDN: viewport-percentage lengths, and why `dvh` differs from `vh`](https://developer.mozilla.org/en-US/docs/Web/CSS/length#viewport-percentage_lengths)
