
Building Performant Sticky Headers and Table of Contents with CSS
Attaching window.addEventListener("scroll", ...) handlers to measure getBoundingClientRect() and toggle a .is-fixed class was once a necessary evil, but it causes main-thread layout thrashing and awkward content jumps when the element is ripped out of normal document flow. With position: sticky, the browser handles the transition between relative and fixed positioning natively on the compositor thread.
However, two subtle CSS layout traps frequently stop position: sticky from working inside modern layouts:
- Grid and Flexbox stretch alignment: By default, grid and flex items stretch to match the full height of their row or track (
align-items: stretch). If a sidebar is already as tall as its parent container, it has zero room to slide—always setalign-self: starton the sticky child. - Overflow clipping on ancestors: Any parent or ancestor with
overflow: hidden,overflow: scroll, oroverflow: autoalters the scrolling container and prevents the sticky element from anchoring to the viewport.
Example
.article-sidebar {
position: -webkit-sticky;
position: sticky;
top: 24px;
/* Critical inside CSS Grid or Flexbox so the sidebar doesn't stretch to 100% height */
align-self: start;
}
Input : Two-column documentation layout with a long article body and an accompanying Table of Contents
Output : Smooth, zero-JavaScript sticky navigation that stays pinned 24px from the top of the viewport until the parent section scrolls past.
Implementation
<header class="site-header">
<nav class="site-nav">Clean Markup Documentation</nav>
</header>
<div class="docs-layout">
<main class="docs-content">
<h1>CSS Layout Architecture</h1>
<p>Long-form technical content that scrolls naturally...</p>
</main>
<aside class="docs-toc" aria-label="Table of Contents">
<h2>On this page</h2>
<ul>
<li><a href="#scrolling-context">Scrolling Context</a></li>
<li><a href="#grid-alignment">Grid Alignment</a></li>
<li><a href="#overflow-ancestors">Overflow Ancestors</a></li>
</ul>
</aside>
</div>
/* 1. Sticky top navigation bar */
.site-header {
position: -webkit-sticky;
position: sticky;
top: 0;
z-index: 100;
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(6px);
border-bottom: 1px solid #e2e8f0;
}
/* 2. Two-column CSS Grid container (avoid overflow: hidden here!) */
.docs-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 40px;
max-width: 1140px;
margin: 0 auto;
padding: 32px 20px;
}
/* 3. Sticky sidebar Table of Contents */
.docs-toc {
position: -webkit-sticky;
position: sticky;
top: 88px; /* 64px header height + 24px breathing room */
align-self: start;
max-height: calc(100vh - 112px);
overflow-y: auto;
padding: 20px;
border-left: 2px solid #04ae92;
background-color: #f8fafc;
}
@media (max-width: 768px) {
.docs-layout {
grid-template-columns: 1fr;
}
.docs-toc {
position: static;
max-height: none;
}
}
Main Point: Whenever position: sticky refuses to stick inside a CSS Grid or Flexbox layout, verify that the sticky element has align-self: start, an explicit threshold like top: 24px, and no ancestor with overflow: hidden.
Credits
Photo by Glenn Carstens-Peters