HTML sticky header

Two CSS declarations make a header stick. Four common ancestor styles stop it working, and none of them produce an error.

An HTML sticky header is a header given position: sticky and top: 0 in CSS.

header {
  position: sticky;
  top: 0;
  z-index: 10;
  background: #0e1014;
  border-bottom: 1px solid #23262d;
}

Both declarations are required. position: sticky with no top value never sticks to anything, and produces no warning of any kind.

A page scrolled halfway down, with the header still visible at the top edge.
A page scrolled halfway down, with the header still visible at the top edge.

HTML sticky header versus fixed header

sticky fixed
Stays in the document flow Yes No
Content slides under it No Yes, unless you pad
Scrolls with the page first Yes, until it reaches top No, pinned from the start
Confined by its parent Yes No, relative to the viewport
Broken by ancestor overflow Yes No

Sticky is the right default for a page header. It needs no padding compensation on the body and it cannot overlap the first paragraph, which is the single most common bug with fixed headers.

Fixed is right when the bar must be visible from the first pixel and the page below is a scrolling region, which is the header and footer case.

The four reasons sticky does nothing

No top value. The most common by a distance. Sticky positions against an offset, and with none given there is nothing to stick to.

An ancestor has overflow set. Any ancestor with overflow: hidden, auto or scroll becomes the scrolling context, and the header sticks inside that box instead of the page. This is frequently a wrapper someone added to clip a decoration.

.wrapper { overflow-x: hidden; }  /* this silently kills sticky inside it */

Replace that with overflow-x: clip, which clips without creating a scroll container.

The parent is not tall enough. A sticky element only travels within its parent. If the header's parent ends after 300 pixels, the header unsticks there and scrolls away with it.

The scroll container has a fixed height with no overflow. Nothing scrolls, so nothing sticks.

The background and the layer

header { background: #0e1014; }

Without an opaque background, the page content scrolls visibly through the header text. It looks like a rendering fault and is simply a missing fill.

z-index matters because the header is now a positioned element competing with whatever else is positioned. Give it a value, and keep it modest so a modal can still appear above it.

Be aware that the header creating a stacking context is exactly what traps modals nested inside it. Keep modals at the end of <body>, or use <dialog> with showModal(), which is drawn above everything.

The same header pinned in place with body text scrolling behind and below it.
The same header pinned in place with body text scrolling behind and below it.

The browser scrolls a target to the very top of the viewport. The header now covers that strip, so the heading you linked to ends up hidden.

:target, h2, h3 { scroll-margin-top: 4.5rem; }

Set the value to the header height plus a little breathing room. This is CSS only and needs no script.

Smooth scrolling is a separate line and worth pairing with a preference check:

html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

Height on small screens

A header that takes a third of a phone screen leaves very little room for the page. Shrink it, or hide part of it.

@media (max-width: 40rem) {
  header { padding: .4rem .75rem; }
  header .subtitle { display: none; }
}

A common pattern is to hide the header on scroll down and show it on scroll up. It works and it costs a scroll listener plus a transform. Decide whether the returned space is worth a component that moves under the reader.

Sticky section headings

The same two declarations work on anything, not only the page header. A heading inside a long list can stick while its own section is on screen and then be pushed off by the next one.

h2 { position: sticky; top: 3.5rem; background: #0e1014; padding: .5rem 0; }

The top value is the page header height, so the two stack rather than overlap. This is where the sticky behaviour earns its keep: each heading is confined to its parent, so it leaves automatically when its section ends.

For that to happen, each section needs to be a real element wrapping its heading and content. A flat sequence of headings and paragraphs with no wrapper gives every heading the whole page as its parent, and they pile up.

<section><h2>Q1</h2><p>...</p></section>
<section><h2>Q2</h2><p>...</p></section>

The same idea applied to a table is in sticky table headers, where the container is the table rather than a section.

Shrinking the header after scroll

If you want the header to compact once the page moves, use an IntersectionObserver rather than a scroll listener.

<div id="sentinel"></div>
new IntersectionObserver(function (e) {
  document.querySelector('header').classList.toggle('is-small', !e[0].isIntersecting);
}).observe(document.getElementById('sentinel'));

The sentinel sits above the header. When it leaves the viewport, the header is pinned. This fires once per state change rather than on every scroll frame.

Printing

A sticky header prints on the first page only, which is usually what you want. If it is repeating or getting in the way:

@media print { header { position: static; } }

Print stylesheets cover the rest.

Checking it

A heading reached by an anchor link, sitting clear of the header rather than under it.
A heading reached by an anchor link, sitting clear of the header rather than under it.

Open the file in the HTML file opener and scroll the full length of the page. Click an in-page link and confirm the heading lands below the header, not under it.

Narrow to phone width and scroll again. Then paste the HTML into a NOS document, where the header renders and sticks at the document's own address, so the reader gets the working page from a link.

The bar itself is covered in navbars, and the same technique applied to a table is in sticky table headers. For the overall page frame, CSS grid is often simpler than stacking positioned boxes.

Questions people ask

How do I make a header stick to the top of the page?

Give it position sticky and top zero. It stays in the normal flow until the page scrolls to that point, then stops moving. No script and no padding compensation is needed.

Why is position sticky not working?

Usually one of four reasons. There is no top value, an ancestor has overflow hidden or auto, the scrolling ancestor is not tall enough to scroll, or the parent element is shorter than the header needs to travel within.

What is the difference between sticky and fixed?

A sticky element stays in the document flow, so the content below never slides under it and no padding is needed. A fixed element is removed from the flow and overlaps content unless you reserve space for it.

Why do my anchor links land under the sticky header?

Because the browser scrolls the target to the very top of the viewport, which the header now covers. Add scroll-margin-top to the target elements, set to the header height.

Keep reading