The table of contents in this case study had valid sticky CSS and still scrolled out of view. DevTools showed position: sticky, the inset was not crossed out, and there was plenty of article content below it. Raising z-index changed nothing.
The failure belonged to a wrapper several levels above the table of contents. That wrapper declared overflow-y: auto, but it had no bounded height and never scrolled. It became the sticky element’s scroll container while the document remained the scrollport the reader actually used.
For the positioning rules behind that behavior, start with the sticky positioning field guide. This walkthrough stays with the investigation: how the wrong scroll owner was found, which fix matched the page, and how the result was verified.
The failing specimen
The page used a fixed site header, an article grid, and a nested documentation panel:
<header class="site-header">CSS documentation</header>
<main class="docs-panel">
<div class="article-layout">
<aside class="article-nav">...</aside>
<article>...</article>
</div>
</main>
:root {
--site-header-block: 4rem;
}
.site-header {
position: fixed;
inset: 0 0 auto;
block-size: var(--site-header-block);
}
.docs-panel {
overflow-y: auto;
padding-block-start: var(--site-header-block);
}
.article-layout {
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);
align-items: start;
}
.article-nav {
position: sticky;
inset-block-start: calc(var(--site-header-block) + 1rem);
}
The document grew with the article, so the browser window scrolled. .docs-panel also qualified as a scroll container because of overflow-y: auto, even though its own scrollTop never changed. The sticky element was waiting for movement in the wrong box.
Inspect the scroll owner, not only the sticky rule
Selecting .article-nav in DevTools confirmed two useful facts: its computed position was sticky, and the inset resolved to a real length. That ruled out a missing threshold or an invalid declaration.
The next step was to walk upward through the ancestors and inspect computed overflow. In the console, with the sticky element selected as $0, this reduced probe made the unexpected boundary obvious:
for (let node = $0.parentElement; node; node = node.parentElement) {
const style = getComputedStyle(node);
const overflow = `${style.overflowX} ${style.overflowY}`;
if (/(auto|scroll|hidden|clip)/.test(overflow)) {
console.log(node, {
overflow,
clientHeight: node.clientHeight,
scrollHeight: node.scrollHeight,
scrollTop: node.scrollTop,
});
}
}
.docs-panel reported auto overflow, equal clientHeight and scrollHeight, and a scrollTop of zero throughout the test. At the same time, document.scrollingElement.scrollTop increased. The computed styles were valid; the assumed scroll ownership was not.
Compare the two legitimate fixes
There were two coherent ways to repair the layout.
The first was to let the document keep owning vertical scroll. That meant removing overflow from .docs-panel. The sticky table of contents would then respond to document scrolling and use an offset that accounted for the fixed header.
The second was to turn .docs-panel into a real application scrollport:
.docs-panel {
block-size: calc(100dvh - var(--site-header-block));
margin-block-start: var(--site-header-block);
overflow-y: auto;
padding-block-start: 0;
}
.article-nav {
inset-block-start: 1rem;
}
That version can make sense for an application shell, but it changes more than sticky positioning. Page Down, anchor navigation, focus scrolling, scroll restoration, and any code observing document scroll now operate against a nested region. It also changes the inset: the fixed header sits outside the panel, so adding the header height again would create unnecessary space.
Both versions make sticky work. Only one matches the page’s intended scrolling model.
Make the ownership decision
This was a documentation page, not an application canvas. Readers expected normal document scrolling, browser find, anchors, and history restoration to work against the page. The document therefore remained the vertical scroll owner.
The wrapper’s overflow rule had originally been added after a wide code sample caused horizontal scrolling. Retaining a nested vertical scrollport would have preserved a workaround rather than the content model. The selected fix removed the wrapper overflow and constrained the actual wide content:
.docs-panel {
min-inline-size: 0;
overflow: visible;
padding-block-start: var(--site-header-block);
}
.article-layout > article {
min-inline-size: 0;
}
.docs-panel pre {
max-inline-size: 100%;
overflow-x: auto;
}
.article-nav {
position: sticky;
inset-block-start: calc(var(--site-header-block) + 1rem);
}
If the oversized child is not already known, use the overflow debugging workflow to identify it. Moving clipping from a page wrapper to the component that owns the excess width prevents an overflow patch from silently redefining sticky behavior.
Verify the before-and-after behavior
The original failure was reproduced before changing CSS: the document scroll position increased, .docs-panel.scrollTop stayed at zero, and the table of contents left the viewport. After the change, the same document scroll caused .article-nav to stop below the fixed header and remain there until the article grid’s end boundary reached it.
The regression check covered the boundaries most likely to expose a second problem:
- At a wide viewport, the table of contents stuck below the fixed header and released at the end of the article grid.
- On a short laptop viewport, a long table of contents remained usable instead of extending beyond the visible block size.
- At 200% zoom, the grid collapsed before the sticky navigation competed with the article for reading space.
- At the narrow layout, the table of contents returned to normal flow and the document remained the only vertical scrollport.
- Heading anchors landed below the fixed header, and keyboard focus was not hidden by either header or navigation.
The final debugging lesson was not that overflow-y: auto is wrong. It was that a scroll container is an ownership decision. When sticky appears inert, verify which ancestor owns scrolling, whether that ancestor actually scrolls, and whether that ownership matches the page before changing the sticky element itself.