
How to Make a Sticky Header with position: sticky
A header that stays visible while the rest of the page scrolls is one of the most common patterns on the web. Navigation stays one click away, the brand stays on screen, and users never have to scroll back to the top to find the search box. For years this meant a scroll listener in JavaScript that toggled a class once the page passed a certain offset. Today, position: sticky does the job in two lines of CSS.
The catch is that sticky positioning has a handful of rules that aren't obvious, and when one of them is broken the header just scrolls away with no error and no warning. In this guide, we'll build a sticky header step by step, look at exactly why sticky sometimes "doesn't work", and cover the polish that makes a sticky header feel good: shadows on scroll, anchor-link offsets, and mobile considerations.
How position: sticky Works
A sticky element is a hybrid between relative and fixed. It sits in the normal document flow like a relatively positioned element, and it keeps its space in the layout. But once scrolling would push it past a threshold you define with top, bottom, left, or right, it sticks at that offset, as if it were fixed.
The key difference from position: fixed is where it sticks. A fixed element is positioned against the viewport. A sticky element is positioned against its nearest scrolling ancestor (the closest ancestor with an overflow value other than visible), and it can only travel within the bounds of its containing block, which is usually its parent element. When the parent scrolls out of view, the sticky element goes with it.
Those two facts explain nearly every sticky bug you'll ever hit, so keep them in mind as we go.
The Basic Sticky Header
Here's a minimal page structure:
<header class="site-header">
<a class="site-header__logo" href="/">TideWave</a>
<nav class="site-header__nav" aria-label="Main">
<a href="/blog">Blog</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
</header>
<main class="page">
<h1>Latest articles</h1>
<!-- long content -->
</main>
And the CSS:
.site-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1.5rem;
background: #ffffff;
border-bottom: 1px solid #e5e7eb;
}
.site-header__nav {
display: flex;
gap: 1.25rem;
}
The two lines that matter are position: sticky and top: 0. Without an offset, sticky has no threshold, so the element behaves exactly like position: relative. That's the single most common reason a sticky header doesn't stick.
The z-index keeps page content from painting on top of the header as it scrolls underneath. A sticky element creates its own stacking context, so any positioned content later in the page with a higher z-index would otherwise show through.
The solid background matters too. A transparent header will stay in place correctly, but the text scrolling beneath it will show through, which looks like a rendering bug.
Why Your Sticky Header Isn't Sticking
If you've added position: sticky and top: 0 and the header still scrolls away, one of the following is almost always the cause.
1. An Ancestor Has overflow Set
This is the big one. If any ancestor of the header has overflow: hidden, overflow: auto, or overflow: scroll, that ancestor becomes the scroll container the header sticks to. If that ancestor doesn't actually scroll (because it's as tall as its content), the header has nowhere to stick.
/* This breaks the sticky header inside it */
.app-shell {
overflow-x: hidden;
}
overflow-x: hidden is a classic culprit, often added to a wrapper to hide horizontal scrollbars caused by some wide element. Setting either axis to a non-visible value computes the other axis to auto as well, so the wrapper becomes a scroll container in both directions.
The modern fix is overflow: clip. It clips content just like hidden, but it does not create a scroll container, so sticky descendants keep working:
.app-shell {
overflow-x: clip;
}
overflow: clip is supported in all current major browsers. Better still, find the element that's causing the horizontal overflow and fix it directly.
To find the offending ancestor quickly, paste this into the DevTools console with the header selected as $0:
let el = $0.parentElement;
while (el) {
const { overflow, overflowX, overflowY } = getComputedStyle(el);
if (
[overflow, overflowX, overflowY].some(
(v) => v !== "visible" && v !== "clip",
)
) {
console.log(el, overflow, overflowX, overflowY);
}
el = el.parentElement;
}
2. The Parent Is Too Short
A sticky element can't leave its containing block. If your header lives inside a wrapper that's exactly as tall as the header, there's no room to travel, and it appears to scroll away normally.
<!-- Header is trapped inside a wrapper of the same height -->
<div class="header-wrapper">
<header class="site-header">...</header>
</div>
<main>...</main>
The fix is to make the header a direct child of body (or of a tall layout container), or to apply position: sticky to the wrapper itself instead of the header.
3. It's a Flex or Grid Item That Stretches
In a flex row or a grid, items stretch to fill the cross axis by default. A sticky sidebar that's been stretched to the full height of the row has no room to move. Add align-self: start to let it keep its natural height:
.layout {
display: grid;
grid-template-columns: 16rem 1fr;
gap: 2rem;
}
.layout__sidebar {
position: sticky;
top: 5rem;
align-self: start;
}
This matters less for a full-width header, but it bites often when you add a sticky table of contents next to an article.
4. The Offset Is Missing or Wrong
Sticky needs at least one of top, right, bottom, or left. Also check for height: 100% on html and body combined with overflow: auto on body, which turns body into the scroll container and can change how the offset is measured.
Adding a Shadow When the Header Is Stuck
A subtle shadow that appears only once content scrolls under the header gives users a clear sense of depth. There's no CSS pseudo-class for "currently stuck" that works everywhere yet, so there are two practical approaches.
Option 1: Scroll-Driven Animation
Scroll-driven animations let you tie an animation to the scroll position of the page. Here the shadow fades in over the first 80 pixels of scroll:
@keyframes header-shadow {
from {
box-shadow: 0 0 0 rgb(0 0 0 / 0);
}
to {
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
}
}
@supports (animation-timeline: scroll()) {
.site-header {
animation: header-shadow linear both;
animation-timeline: scroll(root);
animation-range: 0 80px;
}
}
Scroll-driven animations are supported in Chromium-based browsers and Safari's recent releases; Firefox support has been in progress behind a flag. Wrapping it in @supports means browsers without support simply show a header with no shadow, which is a perfectly acceptable fallback.
Option 2: IntersectionObserver
If you need the effect everywhere, a tiny bit of JavaScript is still the most reliable approach. Place a zero-height sentinel element right above the header and watch it:
<div class="header-sentinel" aria-hidden="true"></div>
<header class="site-header">...</header>
.header-sentinel {
height: 1px;
margin-bottom: -1px;
}
.site-header.is-stuck {
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
}
const header = document.querySelector(".site-header");
const sentinel = document.querySelector(".header-sentinel");
const observer = new IntersectionObserver(([entry]) => {
header.classList.toggle("is-stuck", !entry.isIntersecting);
});
observer.observe(sentinel);
When the sentinel scrolls out of view, the header is stuck, and the class is added. This avoids scroll listeners entirely, so it's cheap on performance.
A Note on Scroll-State Queries
Chromium has shipped scroll-state container queries, which let you style descendants of a sticky element based on whether it's stuck, with no JavaScript:
.site-header {
position: sticky;
top: 0;
container-type: scroll-state;
}
@container scroll-state(stuck: top) {
.site-header__inner {
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
}
}
Note that the query styles descendants, not the container itself, so you need an inner wrapper. Support is limited to Chromium-based browsers at the time of writing, so treat it as a progressive enhancement and check caniuse before relying on it.
Fixing Anchor Links Hidden Behind the Header
Once the header is sticky, clicking an in-page link like #pricing scrolls the target to the very top of the viewport, right underneath the header. The fix is scroll-margin-top on the targets or scroll-padding-top on the scroll container:
:root {
--header-height: 4rem;
}
html {
scroll-padding-top: calc(var(--header-height) + 1rem);
}
scroll-padding-top on html applies to every anchor jump and to keyboard focus scrolling, which is usually what you want. If only certain targets need it, use scroll-margin-top on those elements instead:
.article h2,
.article h3 {
scroll-margin-top: 5rem;
}
Both properties are well supported across all modern browsers.
A Header That Hides on Scroll Down
On small screens, a sticky header eats valuable vertical space. A popular compromise is to hide the header when the user scrolls down and bring it back when they scroll up. CSS handles the animation; a few lines of JavaScript track the direction.
.site-header {
position: sticky;
top: 0;
z-index: 100;
transition: transform 0.25s ease;
}
.site-header.is-hidden {
transform: translateY(-100%);
}
@media (prefers-reduced-motion: reduce) {
.site-header {
transition: none;
}
}
const header = document.querySelector(".site-header");
let lastY = window.scrollY;
window.addEventListener(
"scroll",
() => {
const y = window.scrollY;
const goingDown = y > lastY && y > header.offsetHeight;
header.classList.toggle("is-hidden", goingDown);
lastY = y;
},
{ passive: true },
);
Using transform instead of changing top keeps the animation on the compositor, so it stays smooth. If a menu or search field inside the header has focus, consider skipping the hide so keyboard users don't lose sight of where they are.
Sticky Headers Inside Scrollable Panels
Sticky isn't only for page headers. Because it attaches to the nearest scroll container, it works beautifully inside scrollable panels, like a table with a sticky header row:
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Plan</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<!-- many rows -->
</tbody>
</table>
</div>
.table-wrap {
max-height: 24rem;
overflow: auto;
}
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.data-table th {
position: sticky;
top: 0;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
text-align: left;
padding: 0.5rem 0.75rem;
}
Here overflow: auto on the wrapper is exactly what we want: it becomes the scroll container, and the th cells stick to its top edge. Applying sticky to the th cells rather than to thead gives the most consistent results across browsers. Using border-collapse: separate avoids a long-standing quirk where collapsed borders don't travel with sticky cells.
Best Practices
- Keep it short. A header that takes up a quarter of a phone screen is annoying. Aim for 56 to 72 pixels on mobile.
- Always give it a background. Transparent sticky headers let content bleed through.
- Set a sensible
z-index. Use a documented scale (for example, header at 100, dropdowns at 200, modals at 1000) rather than random large numbers. - Account for anchor links with
scroll-padding-top. - Respect reduced motion for any show/hide animation.
- Avoid
overflow: hiddenon layout wrappers. Useoverflow: clipif you need clipping. - Test on real mobile devices. Mobile browser toolbars that expand and collapse change the viewport height, and sticky elements should behave correctly, but it's worth confirming.
Browser Support
position: sticky itself is supported in every modern browser, including on iOS and Android, and has been for years. You no longer need the -webkit-sticky prefix for any browser in current use. The enhancements are where support varies: overflow: clip and scroll-padding are broadly supported, scroll-driven animations are available in most but not all engines, and scroll-state queries are Chromium-only for now. Everything in this guide degrades gracefully to a plain sticky header.
Conclusion
A sticky header is two declarations: position: sticky and top: 0. When it doesn't work, the cause is nearly always an ancestor with overflow set, a parent that's too short to give the header room, or a missing offset. Fix those, add a background and a z-index, and handle anchor offsets with scroll-padding-top, and you have a robust header with zero JavaScript.
From there, layer on the polish that fits your site: a shadow once content scrolls under it, a hide-on-scroll behavior for small screens, or sticky table headers inside scrollable panels. The same handful of rules applies to all of them.


