You can make a header stick to the top of the page with two CSS declarations: position: sticky and top: 0. This tutorial gives you that, then everything around it that usually goes wrong: why sticky sometimes silently does nothing, how to add a shadow and shrink the header once the page scrolls (with an IntersectionObserver instead of a scroll handler), how to hide the header when people scroll down and bring it back when they scroll up, and how to stop anchor links from landing under the header.

All the code below comes from one HTML file that I tested in Chrome at 1280px and 390px wide, with mouse, keyboard and anchor links, and no console errors. The full file is at the end, split into HTML, CSS and JavaScript.

position: sticky vs position: fixed

Both keep the header visible while you scroll, but they behave differently.

position: sticky position: fixed
Takes up space in the layout Yes, it stays in the normal flow No, content slides under it
Needs a spacer or body padding No Yes, equal to the header height
Positioned relative to Its nearest scrolling ancestor The viewport
Can stop sticking Yes, at the end of its parent No
Browser support All current browsers All browsers

For a site header, sticky is almost always the better choice. Because the header still takes up its own space, you don’t need to guess its height and add padding-top to the body, and you don’t get the classic bug where the first line of content is hidden under a fixed bar on phones where the header wraps to two lines.

Use fixed only when the header must sit on top of something, like a transparent header over a full-screen hero image.

The basic sticky header

This is the whole trick:

.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: #fff;
}
  • top: 0 tells the browser where to stick. Without it, sticky behaves exactly like relative.
  • z-index keeps the header above images, cards and anything else that creates its own layer while scrolling under it.
  • background matters more than it looks. Headers are transparent by default, so without it the page text scrolls visibly behind your links.

Why position: sticky is not working

If you added the CSS above and nothing happens, it is almost always one of these four causes.

1. An ancestor has overflow set

This is the most common one. Sticky elements stick inside their nearest ancestor that scrolls, meaning any ancestor with overflow set to hidden, auto or scroll. If a wrapper div has overflow: hidden (often added to stop a horizontal scrollbar), the header sticks to that wrapper, which never scrolls, so it looks like sticky is broken.

To find it, run this in the browser console with your header selected:

let el = document.querySelector('.site-header').parentElement;
while (el) {
  const o = getComputedStyle(el).overflow;
  if (o !== 'visible') console.log(el, o);
  el = el.parentElement;
}

The fix: remove the overflow from that ancestor, or if you needed it to clip horizontal overflow, use overflow-x: clip instead. clip cuts off content without turning the element into a scroll container, so sticky keeps working.

2. There is no top value

position: sticky on its own does nothing. You need at least one of top, bottom, left or right. For a header that is top: 0.

3. The parent is only as tall as the header

A sticky element can only move within its parent. If your header sits inside a wrapper like <div class="header-wrap"> that contains only the header, the wrapper is exactly the header’s height, so there is no room to stick. Make the header a direct child of <body> (or of a wrapper that contains the whole page).

4. A flex or grid parent stretches it

If the header’s parent is a flex or grid container with align-items: stretch in a direction that makes the header as tall as the content, there is again nothing to scroll past. Setting align-self: start on the sticky element fixes this. This comes up more with sticky sidebars than headers, but it is worth knowing.

Add a shadow and shrink on scroll, without a scroll listener

A common touch is a header that is taller at the top of the page and gets smaller, with a shadow, once you start scrolling. Most tutorials do this by checking window.scrollY in a scroll handler. You don’t need to. An IntersectionObserver can watch a 1px element placed above the header and tell you when it leaves the screen:

<div class="sentinel" aria-hidden="true"></div>
<header class="site-header">...</header>
const header = document.querySelector('.site-header');
const sentinel = document.querySelector('.sentinel');

new IntersectionObserver(([entry]) => {
  header.classList.toggle('is-stuck', !entry.isIntersecting);
}).observe(sentinel);

The browser calls this only when the sentinel crosses the edge of the viewport, not on every scroll frame. The CSS then does the visual work:

.site-header.is-stuck {
  box-shadow: 0 2px 12px rgb(0 0 0 / 0.12);
}
.site-header.is-stuck .inner {
  height: var(--header-h-small);
}

In my test the header was 72px tall at the top and 56px tall after scrolling. One thing to watch: shrinking the header changes the layout height, so the content below shifts up by 16px at the moment it shrinks. Since that happens right as the user starts scrolling, it is rarely noticeable. If it bothers you, shrink only the logo and padding with transform, or keep the height and change only the shadow.

Hide on scroll down, show on scroll up

On phones, a header can take 10% or more of the screen. A popular pattern is to slide it away when the reader scrolls down (they are reading) and bring it back the moment they scroll up (they are probably looking for the menu).

This one does need the scroll direction, so it uses a scroll listener. Keep it cheap: mark it passive, and do the work at most once per frame with requestAnimationFrame:

let lastY = window.scrollY;
let ticking = false;
window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    const y = window.scrollY;
    // Ignore small moves, such as the browser adjusting
    // the scroll position when the header shrinks
    if (Math.abs(y - lastY) >= 24) {
      header.classList.toggle('is-hidden', y > lastY && y > 200);
      lastY = y;
    }
    ticking = false;
  });
}, { passive: true });
.site-header.is-hidden {
  transform: translateY(-100%);
}
.site-header:focus-within {
  transform: none;
}

The y > 200 check keeps the header visible near the top of the page. The 24px threshold matters more than it looks. When the header shrinks, the browser’s scroll anchoring nudges the page up by the same 16px, a few pixels at a time, to keep the content from jumping. Without the threshold, the script reads those nudges as the reader scrolling up and shows the header again right after hiding it. I hit exactly this bug while testing, so keep the threshold if you combine shrinking with hiding. Use transform rather than changing top, because transforms don’t trigger layout and animate smoothly.

The :focus-within rule is the accessibility part. If a keyboard user tabs into the navigation while the header is hidden, the header comes back so they can see where focus is. In testing, I scrolled down until the header was hidden, pressed Shift+Tab, and the header slid back with the FAQ link focused.

Click a link like #pricing and the browser scrolls the target to the very top of the viewport, right under your sticky header. The heading you wanted is covered.

CSS fixes this with one line:

html {
  scroll-padding-top: calc(var(--header-h-small) + 16px);
}

scroll-padding-top on the scrolling element (html for the page) tells the browser to leave that much space when it scrolls to an anchor. It also applies when focus moves into view with the keyboard.

If only some targets need the offset, use scroll-margin-top on those elements instead:

h2[id] {
  scroll-margin-top: 80px;
}

With the setup above, clicking “Pricing” put the Pricing heading about 117px from the top of the screen at both widths, clearly below the header.

Accessibility checklist for sticky headers

  • Use real landmarks. A <header> at the top level and a <nav aria-label="Main"> let screen reader users jump straight to them.
  • Add a skip link. Because the header is on screen all the time, a “Skip to content” link as the first focusable element saves keyboard users from tabbing through the menu on every page.
  • Never hide focus. Keep the :focus-within rule, and give links a visible :focus-visible outline.
  • Watch the height on small screens. WCAG 2.2 success criterion 2.4.11 (Focus Not Obscured) asks that a focused element is not completely hidden by sticky content. A tall header is the usual culprit. Keep it compact on phones, and scroll-padding-top helps here too.
  • Respect reduced motion. Turn off the slide and shrink animations for people who ask for less motion:
@media (prefers-reduced-motion: reduce) {
  .site-header,
  .site-header .inner {
    transition: none;
  }
}

If your header also needs a mobile menu, the responsive navbar tutorial covers the hamburger button, aria-expanded and closing with Escape. Everything in it works inside the sticky header from this post.

The full code

Here is the complete, tested example. Paste the three parts into one HTML file (CSS in a <style> tag, JavaScript in a <script> at the end of the body).

HTML

<a class="skip-link" href="#main">Skip to content</a>
<div class="sentinel" aria-hidden="true"></div>
<header class="site-header">
  <div class="inner">
    <a class="logo" href="#">Acme</a>
    <nav aria-label="Main">
      <ul>
        <li><a href="#features">Features</a></li>
        <li><a href="#pricing">Pricing</a></li>
        <li><a href="#faq">FAQ</a></li>
      </ul>
    </nav>
  </div>
</header>
<main id="main" tabindex="-1">
  <section id="features"><h2>Features</h2><p>Scroll down. The header stays at the top.</p></section>
  <section id="pricing"><h2>Pricing</h2><p>This heading is not hidden under the header.</p></section>
  <section id="faq"><h2>FAQ</h2><p>Questions and answers.</p></section>
</main>

CSS

:root {
  --header-h: 72px;
  --header-h-small: 56px;
}
html {
  scroll-padding-top: calc(var(--header-h-small) + 16px);
}
body {
  margin: 0;
  font-family: system-ui, sans-serif;
  line-height: 1.6;
  color: #1a1a1a;
  background: #fff;
}
.skip-link {
  position: absolute;
  left: 16px;
  top: -100px;
  z-index: 200;
  padding: 8px 16px;
  background: #1a1a1a;
  color: #fff;
}
.skip-link:focus {
  top: 8px;
}
.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: #fff;
  transition: box-shadow 0.2s ease, transform 0.25s ease;
}
.site-header .inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  max-width: 1100px;
  margin: 0 auto;
  padding: 0 16px;
  height: var(--header-h);
  transition: height 0.2s ease;
}
.site-header.is-stuck {
  box-shadow: 0 2px 12px rgb(0 0 0 / 0.12);
}
.site-header.is-stuck .inner {
  height: var(--header-h-small);
}
.site-header.is-hidden {
  transform: translateY(-100%);
}
/* Never hide the header while someone is using it */
.site-header:focus-within {
  transform: none;
}
.logo {
  font-weight: 700;
  font-size: 1.25rem;
  color: inherit;
  text-decoration: none;
}
.site-header nav ul {
  display: flex;
  gap: 16px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.site-header nav a {
  color: inherit;
  text-decoration: none;
  padding: 8px 4px;
}
.site-header nav a:hover {
  text-decoration: underline;
}
a:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: 2px;
}
.sentinel {
  height: 1px;
}
main {
  max-width: 1100px;
  margin: 0 auto;
  padding: 0 16px;
}
section {
  min-height: 90vh;
  padding-top: 24px;
  border-top: 1px solid #eee;
}
@media (prefers-reduced-motion: reduce) {
  .site-header,
  .site-header .inner {
    transition: none;
  }
}

JavaScript

const header = document.querySelector('.site-header');
const sentinel = document.querySelector('.sentinel');

// 1. Shadow and shrink once the page has scrolled past the top
new IntersectionObserver(([entry]) => {
  header.classList.toggle('is-stuck', !entry.isIntersecting);
}).observe(sentinel);

// 2. Hide on scroll down, show on scroll up
let lastY = window.scrollY;
let ticking = false;
window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    const y = window.scrollY;
    // Ignore small moves, such as the browser adjusting
    // the scroll position when the header shrinks
    if (Math.abs(y - lastY) >= 24) {
      header.classList.toggle('is-hidden', y > lastY && y > 200);
      lastY = y;
    }
    ticking = false;
  });
}, { passive: true });

The <section> elements have a min-height only so there is something to scroll in the demo. Remove that in a real page.

If you would rather start from a finished design, the free HTML header templates include sticky, transparent and centered layouts you can copy into any project.

FAQ

Why is my position: sticky header not sticking?

Usually an ancestor element has overflow: hidden, auto or scroll, which makes the header stick inside that element instead of the page. The other common causes are a missing top: 0 and a parent element that is no taller than the header itself.

Should I use position: sticky or position: fixed for a header?

Use sticky in most cases. It keeps the header in the normal layout, so you don’t need extra padding on the body to stop content hiding under it. Use fixed when the header has to overlay content, such as a transparent header on top of a hero image.

Add scroll-padding-top to the html element with a value a little larger than your header height. For individual elements, use scroll-margin-top instead.

Do I need JavaScript for a sticky header?

No. The sticky behaviour is pure CSS. JavaScript is only needed for extras: adding a shadow or shrinking once the page scrolls (an IntersectionObserver is enough), and hiding the header on scroll down, which needs to know the scroll direction.

Does position: sticky work on mobile browsers?

Yes. It works in all current versions of Chrome, Safari, Firefox and Edge, including on iOS and Android. The same overflow rules apply, so check ancestors for overflow if it fails only on certain pages.

csshtmlheadersjavascriptaccessibility
Written by

Amir Iqbal

Full-stack developer and the maker of every product in this store. I write about running self-hosted software and the problems our products solve.

About the store