CSS

CSS position: sticky — How It Really Works

W
W3Tweaks Team
Frontend Tutorials
May 28, 2026 12 min read
CSS position: sticky — How It Really Works
You add position: sticky; top: 0 to a header and nothing happens. No error — it just silently refuses to stick. This guide explains how sticky really works, the three traps that break it, and the patterns for headers, sidebars, and table headers.

You add position: sticky; top: 0 to a header. Nothing happens. You Google it, double-check the syntax, and it still doesn’t work. No error in the console — it just silently refuses to stick.

This is the most frustrating thing about position: sticky: it fails without telling you why. This guide explains exactly how sticky works, the three specific traps that break it, and how to build the most common sticky patterns correctly every time.

Live Demo

Live Demo Open in tab

Three tabs: static vs sticky side by side, the three traps that silently break sticky, and real-world patterns — sticky nav, sidebar, and table header.

How position: sticky Works

position: sticky is a hybrid between relative and fixed. An element with sticky positioning behaves in two phases:

Phase 1 — Scrolling toward the threshold: the element behaves exactly like position: relative. It sits in normal document flow and scrolls with the page.

Phase 2 — Once it hits the threshold: the element locks in place like position: fixed — but only within the boundaries of its parent container. When the parent scrolls out of view, the sticky element goes with it.

.site-header {
  position: sticky;
  top: 0; /* lock to the top of the scroll container */
  z-index: 50;
  background: #fff;
}

That threshold — the top, bottom, left, or right value — is required. Without it, sticky never activates.

If you’re new to CSS positioning in general, the full positioning guide walks through static, relative, absolute, fixed, and sticky side by side.

The Difference Between sticky and fixed

Both lock elements in place during scrolling, but they behave very differently:

position: fixedposition: sticky
Reference pointThe viewportIts scroll container
In document flowNo — removed from flowYes — stays in flow
Affects layoutNo — siblings ignore itYes — siblings see it
ScopeAlways visible anywhere on pageOnly visible within its parent
Use caseFloating buttons, persistent modalsHeaders, TOC, table columns

The critical difference: fixed is ripped out of the document flow — it doesn’t push siblings around and covers content. sticky stays in flow — it occupies space and behaves predictably within a layout.

The Three Traps That Silently Break Sticky

These are the three reasons sticky stops working. Every sticky bug in existence comes from one of these.

Trap 1 — Missing threshold value

This is the most common cause. position: sticky requires at least one of top, bottom, left, or right. Without it, the browser doesn’t know where to lock the element and it stays in normal flow forever.

/* Sticky never activates — no threshold */
.header {
  position: sticky;
}

/* Locks to top of scroll container */
.header {
  position: sticky;
  top: 0;
}

/* Also valid — locks 20px from the top */
.header {
  position: sticky;
  top: 20px;
}

Trap 2 — An ancestor has overflow set

This is the trap that stumps experienced developers. If any ancestor element in the DOM has overflow set to hidden, auto, or scroll, that ancestor becomes the scroll container — and sticky positions relative to it, not the page.

If that ancestor isn’t the one actually scrolling (or if it’s too small), sticky appears broken.

/* This silently kills sticky on all descendants */
.page-wrapper {
  overflow: hidden; /* commonly added to "fix" layout issues */
}

.sticky-header {
  position: sticky;
  top: 0; /* never works — .page-wrapper is now the scroll container */
}

Fix option 1 — Remove overflow from the ancestor:

.page-wrapper {
  /* remove: overflow: hidden; */
}

Fix option 2 — Use overflow: clip instead of overflow: hidden:

.page-wrapper {
  overflow: clip; /* does not create a scroll container — sticky still works */
}

overflow: clip is the modern replacement for overflow: hidden when you just want to prevent content from visually escaping — without breaking sticky positioning.

Fix option 3 — Move the overflow to a child wrapper:

.layout {
  display: grid;
  grid-template-columns: 1fr 3fr;
}

.sticky-nav {
  position: sticky;
  top: 0; /* works — no overflow on ancestors */
}

.content-area {
  overflow: auto; /* overflow is a sibling, not a parent */
}

Trap 3 — The parent container is too short

position: sticky only activates while you’re scrolling through the parent. If the parent is barely taller than the sticky element itself, there’s no scrollable space and the element has nowhere to stick.

This is why sticky table-of-contents sidebars appear to stop working at the bottom of an article — once you scroll past the parent section, the sticky element scrolls away with it. That’s correct behaviour, but it surprises people.

/* Parent is only slightly taller than the sticky child — no room to stick */
.section {
  height: 200px; /* sticky header is 60px — only 140px of scroll space */
}

.section-header {
  position: sticky;
  top: 0;
  height: 60px;
}

/* Parent is tall — sticky has plenty of room to activate */
.section {
  min-height: 600px;
}

Real-World Pattern 1: Sticky Site Header

The most common use case — a navigation bar that scrolls with the page and then locks:

<header class="site-header">
  <nav class="nav">
    <a class="nav-logo" href="/">W3Tweaks</a>
    <ul class="nav-links">
      <li><a href="/css/">CSS</a></li>
      <li><a href="/javascript/">JavaScript</a></li>
      <li><a href="/html/">HTML</a></li>
    </ul>
  </nav>
</header>

<main>
  <!-- hero section, then content... -->
</main>
.site-header {
  position: sticky;
  top: 0;
  z-index: 50; /* must sit above all page content */
  background: rgba(11, 15, 26, 0.95);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}

.nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 24px;
  height: 60px;
}

html, body {
  overflow-x: clip; /* safe alternative to overflow: hidden */
}

The z-index is essential here — as the page scrolls and content passes under the header, the header must paint above everything. If you’re not sure why z-index: 50 is enough (or how to pick the right value), the z-index and stacking contexts guide walks through it.

Real-World Pattern 2: Sticky Sidebar Table of Contents

A sidebar that follows the user as they read a long article:

<div class="article-layout">
  <article class="article-body">
    <!-- long article content -->
  </article>
  <aside class="article-sidebar">
    <div class="toc">
      <h3>Table of Contents</h3>
      <nav>
        <a href="#how-it-works">How it works</a>
        <a href="#the-traps">The three traps</a>
        <a href="#patterns">Real-world patterns</a>
      </nav>
    </div>
  </aside>
</div>
.article-layout {
  display: grid;
  grid-template-columns: 1fr 260px;
  gap: 40px;
  align-items: start; /* REQUIRED: without this, sidebar stretches to article height */
}

.toc {
  position: sticky;
  top: 80px; /* 80px = height of sticky header + gap */
}

The align-items: start on the grid is critical. Without it, the sidebar column stretches to match the article height — sticky would technically work, but you’d never see it activate because the sidebar fills the full column.

Real-World Pattern 3: Sticky Table Header

For long data tables where you need column labels visible at all times:

<div class="table-wrapper">
  <table>
    <thead>
      <tr>
        <th>Tutorial</th>
        <th>Category</th>
        <th>Read Time</th>
      </tr>
    </thead>
    <tbody>
      <!-- many rows -->
    </tbody>
  </table>
</div>
.table-wrapper {
  overflow-y: auto; /* this IS the scroll container */
  max-height: 400px;
  border-radius: 12px;
  border: 1px solid rgba(255,255,255,0.07);
}

thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: #1a2235; /* must be opaque — sticky elements are transparent by default */
}

Important: the background on thead th must be opaque. Sticky elements sit on top of scrolling content — if the background is transparent, the rows below will show through the header as they scroll past. For more on styling data tables themselves, see the CSS tables guide.

Sticky with a Variable Offset

When you have a sticky header, other sticky elements must account for its height:

:root {
  --header-height: 60px;
}

.site-header {
  position: sticky;
  top: 0;
  height: var(--header-height);
  z-index: 50;
}

/* Sidebar TOC starts below the sticky header */
.toc {
  position: sticky;
  top: calc(var(--header-height) + 24px);
}

/* Section headers also account for the sticky header */
.article-section {
  scroll-margin-top: calc(var(--header-height) + 16px);
}

scroll-margin-top is the unsung hero here — it shifts the scroll destination for anchor links so content doesn’t hide under the sticky header when you navigate to a section.

Common Gotchas

1. sticky requires a non-static scroll container to exist

/* If html and body have no overflow, sticky needs the page to scroll */
/* Make sure there's actually content tall enough to scroll */
body {
  /* Don't set height: 100vh on body if you want page-level sticky to work */
}

2. Sticky table cells work differently across browsers

/* Use thead th, not tr */
thead th {
  position: sticky;
  top: 0;
}

/* Sticky on tr is not reliable across browsers */
thead tr {
  position: sticky; /* inconsistent support */
  top: 0;
}

3. Safari once needed -webkit-sticky — no longer required

/* Modern browsers — no prefix needed */
.header {
  position: sticky;
  top: 0;
}

/* Old Safari 6.1–12 required this prefix — ignore unless supporting very old Safari */
/* position: -webkit-sticky; */

4. Sticky inside a flexbox column needs align-self: flex-start

/* Flex stretches the sidebar to full column height — sticky activates immediately */
.layout {
  display: flex;
}

/* align-self: flex-start lets the sidebar shrink to content height */
.sidebar {
  align-self: flex-start;
}

.toc {
  position: sticky;
  top: 20px;
}

Browser Support

position: sticky is supported in all modern browsers:

  • Chrome 56+
  • Firefox 32+
  • Safari 13+ (no prefix required)
  • Edge 16+

This covers more than 97% of global users as of 2026. No polyfill needed. For exact, up-to-date browser stats, check caniuse.com/css-sticky or the MDN reference for position: sticky.

Key Takeaways

  • position: sticky is a hybrid — relative until the threshold, then fixed within its parent
  • Always set a thresholdtop, bottom, left, or right — without it sticky never activates
  • Any ancestor with overflow: hidden/auto/scroll becomes the scroll container and breaks page-level sticky — use overflow: clip instead
  • The parent must be taller than the sticky element for it to activate
  • Use align-items: start on grid/flex parents so the sticky element’s parent doesn’t stretch
  • Use scroll-margin-top on section targets to prevent content hiding under sticky headers
  • Background on sticky elements must be opaque — transparent sticky headers show scrolling content underneath
  • overflow: clip is the modern, sticky-safe alternative to overflow: hidden

FAQ

Why is my sticky element not sticking?

Run through this checklist: (1) Is top, bottom, left, or right set? (2) Does any ancestor have overflow set to anything other than visible? (3) Is the parent container tall enough — taller than the sticky element with room to scroll? One of these three is always the cause.

What is the difference between position: sticky and position: fixed?

fixed is always positioned relative to the viewport and is removed from document flow — it overlays content and doesn’t push siblings. sticky stays in document flow, occupies space, and is bounded by its parent — it disappears when its parent scrolls out of view. Use fixed for truly persistent UI like floating action buttons; use sticky for headers, sidebars, and table columns that belong to a specific section of the page.

Why does sticky stop working at the bottom of the page?

Because sticky is always bounded by its parent container. Once the parent scrolls completely out of view, the sticky element exits with it. This is correct behaviour. If your sticky sidebar disappears halfway through an article, the sidebar’s parent container is ending there. Make the parent taller or restructure the HTML so the sticky element’s parent wraps the full content area.

Can I use overflow: hidden and position: sticky together?

Not on the same ancestor chain. overflow: hidden on any ancestor of a sticky element creates a scroll container that traps the sticky positioning. Replace it with overflow: clip — it prevents visual overflow the same way, but does not create a scroll container, so sticky still works.

Does sticky work inside a CSS Grid or Flexbox layout?

Yes, but you need one extra property. Sticky inside a flex column or grid column will only work if the parent has align-items: start (or the item itself has align-self: start). Without it, flex/grid stretches the sidebar to fill the full column height, so the sticky element fills its parent completely and has no room to scroll-stick.

What is scroll-margin-top and why does it matter with sticky headers?

When you click an anchor link (#section-2), the browser scrolls to that element and positions it at the very top of the viewport — hiding it under your sticky header. scroll-margin-top adds an invisible margin above the element for scroll-snapping purposes only. Set it to the height of your sticky header and anchor links will land at the right position every time.

h2, h3 {
  scroll-margin-top: 80px; /* height of sticky header */
}