Clean Markup

Building Performant Sticky Headers and Table of Contents with CSS

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:

  1. 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 set align-self: start on the sticky child.
  2. Overflow clipping on ancestors: Any parent or ancestor with overflow: hidden, overflow: scroll, or overflow: auto alters 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

← Home