Give the header position: sticky and top: 0, plus a background and a z-index, and it stays pinned while the page scrolls. It only sticks inside its parent, and an ancestor with overflow set can stop it. For a header that hides on scroll, toggle a translateY class from a scroll listener.
| position: sticky | position: fixed | |
|---|---|---|
| Takes up space in the layout | Yes | No, add padding to the body |
| Pinned relative to | Nearest scrolling ancestor | The viewport |
| Needs an inset such as top: 0 | Yes, or it acts like relative | Usually |
| Creates a stacking context | Yes | Yes |
| Stops at the end of its parent | Yes | No |
Updated
Sticky headers are one line of CSS until they are not: the header refuses to stick, anchor links land under it, or a dropdown slides behind the content. This page covers the line of CSS, then each of those failures, with a live scroll demo you can switch between plain, shrinking and hiding modes.
Switch between plain, shrink and hide-on-scroll, then scroll inside the box.
Scroll the frame. The header stays at the top and the anchor link stops below it thanks to scroll-padding-top.
<style>
html { scroll-padding-top: 56px }
body { margin: 0 }
.site-header { position: sticky; top: 0; z-index: 50; height: 48px; display: flex; align-items: center; gap: 16px;
padding: 0 16px; background: rgb(255 255 255 / .92); border-bottom: 1px solid #e2e8f0 }
.site-header a { color: #1d4ed8 }
section { min-height: 260px; padding: 16px }
section:nth-child(odd) { background: #f8fafc }
</style>
<header class="site-header"><strong>Brand</strong><a href="#pricing">Pricing</a></header>
<main>
<section><p>Scroll down.</p></section>
<section id="pricing"><h2>Pricing</h2></section>
<section></section>
</main>The script only sets data attributes. Every visual change lives in CSS.
.site-header {
position: sticky;
top: 0;
z-index: 50;
transition: transform 200ms ease;
}
.site-header[data-hidden="true"] {
transform: translateY(-100%);
}
.site-header[data-scrolled="true"] {
box-shadow: 0 1px 8px rgb(0 0 0 / 0.08);
}
@media (prefers-reduced-motion: reduce) {
.site-header { transition: none; }
}Returns two booleans you can spread onto the header as data attributes. The 8px threshold ignores tiny jitters.
import { useEffect, useState } from "react";
export function useHideOnScroll(threshold = 8) {
const [state, setState] = useState({ hidden: false, scrolled: false });
useEffect(() => {
let last = window.scrollY;
const onScroll = () => {
const y = window.scrollY;
const delta = y - last;
if (Math.abs(delta) < threshold) return;
setState({ hidden: delta > 0 && y > 64, scrolled: y > 0 });
last = y;
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [threshold]);
return state;
}
// <header className="site-header" data-hidden={hidden} data-scrolled={scrolled}>| Symptom | Cause | Fix |
|---|---|---|
| Header scrolls away | No top value | Add top: 0 |
| Header scrolls away | Ancestor has overflow: hidden | Move overflow to a smaller wrapper, or use overflow: clip |
| Sticks, then leaves early | Parent is short | Place the header in body or a full-height wrapper |
| Anchor headings hidden | No scroll offset | scroll-padding-top on html |
| Menu goes behind content | Header stacking context too low | z-index on the header itself |
| Content shows through | Transparent background | Give the header a background colour |
A sticky element starts in normal flow. Once scrolling would carry it past its inset, top: 0 for a header, it holds that position until the bottom of its parent arrives and pushes it away.
The box it sticks to is the nearest ancestor with a scrolling mechanism, which includes overflow: hidden. That is why a wrapper added to clip a carousel can silently break a header far away from it.
Compare the current scroll position with the last one. Moving down past a small threshold hides the header with transform: translateY(-100%); any upward movement brings it back.
Animate transform rather than top or height, since a transform does not trigger layout. Listen to scroll with the passive option so the browser never waits for your handler.
Changing the header height while sticky moves the content under it. Keep the outer height fixed and shrink the logo with transform: scale, or reduce padding inside a header that is already out of the way.
A data attribute such as data-scrolled on the header keeps the style rules in CSS and the script down to one line.
Three usual causes: no inset value such as top: 0, a parent that ends right after the header so there is no room to stick, or an ancestor with overflow other than visible, which becomes the box the header sticks to. Check them in that order.
Sticky is simpler because it keeps its place in the layout, so nothing jumps under it at the top of the page. Fixed is useful when the header must sit over a full-screen hero, but then you add padding to the page yourself.
Add scroll-padding-top to the scrolling container, usually html, equal to the header height. For one-off targets you can instead put scroll-margin-top on the heading itself.
Sticky positioning creates a stacking context, so anything inside the header can only stack within it. Raise the header itself with a z-index above later content, for example z-index: 50, rather than raising the dropdown.
Related: CSS position explained, z-index and stacking contexts, Scroll animations, Website header examples, Respecting prefers-reduced-motion.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.