Here's the short answer first. A sticky header is a normal header with position: sticky and top: 0, placed where its parent is as tall as the page and no parent has overflow: hidden. The hide-on-scroll trick is a small script that compares the current scroll position with the last one and adds a class that slides the header up with a transform. The hero uses min-height with the svh unit, never a fixed height, and a font size built with clamp().
If you're a beginner, read it from the top and click both live demos, because seeing a header that refuses to stick teaches more than any definition. If you already build layouts every day, jump to the section on why the header doesn't stick and then to the scroll script. Those are the places where people who know the basics still lose an afternoon. The parts about focus, reduced motion and short screens are for everyone who ships to real users.
info
Tested with: Chrome 156 (headless) on 2026-11-13, at 760, 375 and 320 pixels wide. Every demo and snippet in this article was run there and the numbers I mention, like header heights and font sizes, come from those runs. Safari and Firefox were not tested for this article, so click the demos in your own browser too.
This article is part of a series I call Concepts in practice. Instead of learning CSS properties one by one from a list, we take a piece of a website you already know from daily life, rebuild it, and look at the tricks behind it. You learn faster when you already know what the finished thing should look like.
The header and the hero are the first two things on nearly every landing page, shop and app. They look simple. A strip with a logo and three links, then a big title. And yet they're where a lot of beginner sites quietly fall apart: the header that sticks on the laptop but not in the real project, the anchor link that lands with the heading hidden under the header, the hero that's cut off on a phone because of the address bar.
By the end you'll have built a page that does four things:
- The header stays at the top of the screen while you scroll.
- It slides away when you scroll down to read, and comes back the moment you scroll up a little.
- It never hides the heading you jumped to or the link you're focused on with the keyboard.
- The first section fills the screen on a laptop and on a phone, without cutting anything off.
warning
Rebuild the pattern, never the brand. A sticky header belongs to nobody. A company's logo, wording, photos and colours do belong to someone. Everything in this article, from the bakery name to the colours, is made up. Do the same when you practise, and especially before anything you build goes on a public page.
Let's get the words straight first. The header is the strip at the top of a page with the logo and the main links. The hero is the first big section under it: a headline, one or two lines of text and usually one button. And sticky is a value of the CSS position property that makes an element scroll normally until it reaches a line you choose, and then stay on that line.
Here's a comparison from normal life. Think of a fridge magnet on a sheet of paper you're sliding up the fridge door. The magnet moves with the paper until it hits the top edge of the door, and then it stays there while the paper keeps sliding. But it can never leave the paper. When the bottom of the sheet arrives, the magnet goes with it. That last part matters, and it's the reason most broken sticky headers are broken.
The MDN reference for position puts it more formally: a sticky element is treated as relatively positioned until its containing block crosses a threshold, such as top set to something other than auto, and then it's treated as stuck "until meeting the opposite edge of its containing block". The containing block is, for our purposes, the parent. That's the sheet of paper.
Why I reach for sticky and not fixed
You'll also see position: fixed used for headers. A fixed element is glued to the screen from the start, no matter where its parent is. That sounds simpler, so why not use it?
Because a fixed element is taken out of the page. The browser stops reserving space for it, so the first lines of your content slide underneath it. The usual fix is to add padding to the top of the body equal to the header's height. That works until the header's height changes, and it changes more often than you'd think. In the demo later in this article the header is 56 pixels tall on a laptop and 69 pixels tall on a 375 pixel phone, because the links wrap onto a second line. A fixed padding would be wrong on one of the two.
A sticky header keeps its place in the page. Its space stays reserved, nothing slides under it at the top, and if it grows, the page simply moves down. Fixed still has its uses, like a chat button in the corner, but for a header, sticky is the better default.
Here's the thing. Almost every complaint about sticky comes down to three causes, and none of them is a browser bug. The demo below has a small scrolling box with a header inside. Pick a version from the list, then scroll inside the box. The first version works. The other three break in the three most common ways.
<label class="pick">Version:
<select data-mode>
<option value="works">Works: header is a direct child, top: 0</option>
<option value="wrapped">Broken: header wrapped in a div</option>
<option value="overflow">Broken: a parent has overflow: hidden</option>
<option value="notop">Broken: no top value</option>
</select>
</label>
<p class="hint">Scroll inside the box below.</p>
<div class="page" data-page></div>.pick {
display: block;
font-size: 0.9rem;
}
.pick select {
max-width: 100%;
margin-top: 0.25rem;
font: inherit;
}
.hint {
margin: 0.5rem 0;
font-size: 0.85rem;
color: #555;
}
.page {
height: 220px;
overflow-y: auto;
border: 1px solid #ccc;
border-radius: 0.5rem;
background: #fff;
}
.bar {
position: sticky;
top: 0;
padding: 0.75rem 1rem;
background: #1f3a5f;
color: #fff;
font-weight: 700;
}
.bar.no-top {
top: auto;
}
.clipper {
overflow: hidden;
}
.page p {
margin: 0;
padding: 0.75rem 1rem;
border-bottom: 1px solid #eee;
}const page = document.querySelector("[data-page]");
const select = document.querySelector("[data-mode]");
const rows = Array.from({ length: 12 }, (_, i) => `<p>Line ${i + 1} of the page content</p>`).join("");
const versions = {
works: `<header class="bar">Header</header>${rows}`,
wrapped: `<div><header class="bar">Header</header></div>${rows}`,
overflow: `<div class="clipper"><header class="bar">Header</header>${rows}</div>`,
notop: `<header class="bar no-top">Header</header>${rows}`,
};
function render() {
page.innerHTML = versions[select.value];
page.scrollTop = 0;
}
select.addEventListener("change", render);
render();The script in this demo only swaps the markup inside the box when you pick a version. The interesting part is the difference between the four strings in the versions object, so let's go through them.
The parent is only as tall as the header
In the second version the header sits inside an extra div, and that div contains nothing else. So the div is exactly as tall as the header. Remember the magnet: it can only move inside its sheet of paper. Here the paper is the size of the magnet, so there is nowhere to move. The header is technically sticky, it just has zero room to do it.
This happens all the time in component frameworks, where a header component gets wrapped in a div by a layout file or a provider without you noticing. The fix is to make the header a direct child of something as tall as the page, usually the body, or to put the sticky rule on the wrapper instead of on the header.
A parent has overflow: hidden
In the third version the wrapper contains the whole page, so it's tall enough. But it has overflow: hidden. MDN describes exactly this case: a sticky element sticks to its nearest ancestor that has a "scrolling mechanism", which is created when overflow is hidden, scroll, auto or overlay, "even if that ancestor isn't the nearest actually scrolling ancestor". Your header is now sticky inside a box that never scrolls. So it never sticks. You can read the note yourself in the position reference.
Why would anyone put overflow: hidden on a page wrapper? Usually to kill a horizontal scrollbar. Something on the page is a few pixels too wide, the page wobbles sideways on a phone, and overflow-x: hidden makes the wobble go away. And here's the part most people miss: you set only overflow-x, but the browser computes overflow-y as auto. In the test for this article, the wrapper reported "hidden auto". So a rule that looks like it only deals with sideways scrolling quietly turns the wrapper into a vertical scroll container too, and your sticky header dies.
The fix is overflow-x: clip. The overflow reference says that with clip "the element box is not a scroll container", which is exactly what we need. It still cuts off whatever pokes out sideways, but it doesn't create a scrolling mechanism. In the same test, the wrapper with clip instead of hidden kept the header at the top.
.page-wrapper {
overflow-x: clip;
}- .page-wrapper is whatever element wraps your page. Use your own class name.
- overflow-x: clip cuts off anything wider than the wrapper, like overflow-x: hidden does, but the wrapper does not become a scroll container, so a sticky child inside it keeps working.
- If you remove this rule completely, the horizontal wobble comes back. The better long-term fix is to find the element that's too wide, but clip is a safe patch while you look.
One more result from that test: overflow-x: hidden on the body alone did not break the header in Chrome, because the browser hands the body's overflow over to the window in that case. On any wrapper div, it did. Don't rely on the body exception. Use clip and you don't have to remember it.
No top value
The fourth version is the simplest mistake. The header has position: sticky but no top. Sticky needs to know where the line is. Without top, or another inset like bottom, there is no line, and the element behaves like a normal relatively positioned box. MDN says it in one sentence: at least one inset property needs to be set. Add top: 0 and it works.
tip
When a sticky element won't stick, don't stare at its own CSS. Walk up its parents. Open the browser's DevTools, go to the Console tab, and paste the small script below. It lists every ancestor that has an overflow value which creates a scrolling mechanism. Very often, the culprit is in that list.
let el = document.querySelector(".site-header").parentElement;
while (el) {
const style = getComputedStyle(el);
const values = [style.overflowX, style.overflowY];
if (values.some((v) => v !== "visible" && v !== "clip")) {
console.log(el, style.overflowX, style.overflowY);
}
el = el.parentElement;
}- The first line starts at the header's parent, because the header's own overflow doesn't matter here.
- getComputedStyle(el) returns the final values the browser uses, after every stylesheet has had its say. That's important, because the rule that breaks you might live in a file you never opened.
- We check overflowX and overflowY separately, because as we just saw, setting one changes the other.
- visible and clip are the two values that do not create a scrolling mechanism, so anything else gets logged.
- el.parentElement walks one level up each time, until there are no more parents and the loop ends at the html element.
Run on the broken version of the bakery page, it printed one line: the page wrapper, with "hidden auto". That's your answer in a second instead of an afternoon.
I'll be honest about why I start here. When I started, I built pages from my head until they looked good. No plan, no thought for semantics, no thought for people using a keyboard or a screen reader. A header was a div with some links in it. It looked fine, and it was wrong in ways I couldn't see. So now the structure comes first, and the styling comes after.
If HTML elements are new to you, read HTML and CSS From Scratch: Build Your First Web Page first and come back. Here's the skeleton of our page:
<a class="skip" href="#main">Skip to content</a>
<header class="site-header" data-header>
<a class="logo" href="#top">Harbor Lane Bakery</a>
<nav aria-label="Main">
<ul>
<li><a href="#menu">Menu</a></li>
<li><a href="#visit">Visit</a></li>
<li><a href="#order">Order</a></li>
</ul>
</nav>
</header>
<main id="main">
<section class="hero" id="top" aria-labelledby="hero-title">
<div class="hero-text">
<h1 id="hero-title">Fresh bread, baked before you wake up</h1>
<p>Sourdough, rye and cinnamon rolls from one small oven. Order by 8 pm, pick up at 7 am.</p>
<a class="button" href="#order">Order for tomorrow</a>
</div>
</section>
</main>- The first link, "Skip to content", is for keyboard users. It's the first thing they reach with the Tab key, and it jumps straight past the header to the main content. In the full demo it's hidden above the screen until it receives focus.
- <header> tells the browser and screen readers that this is the page's header. A div would look the same and say nothing.
- data-header is a hook for our script later. I keep JavaScript hooks in data attributes so that renaming a CSS class can never break the script.
- <nav aria-label="Main"> marks the links as navigation and gives the area a name. A screen reader user can then jump to "Main navigation" directly.
- The links are a list, because they are a list. Screen readers announce "list, 3 items", which tells the user how much is coming.
- <main id="main"> holds the page's main content and is the target of the skip link.
- The hero is a <section> with aria-labelledby pointing at the h1, so the section is named by its own headline.
- There is exactly one h1 on the page, in the hero. The bakery name in the header is a link, not a heading.
Notice that the header is a direct child of the body. Not wrapped in a div, not inside main. That one decision removes the first cause of a non-sticking header before we've written a single line of CSS.
:root {
--header-h: 3.5rem;
}
html {
scroll-padding-top: calc(var(--header-h) + 1rem);
}
.site-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.25rem 1rem;
min-height: var(--header-h);
padding: 0.5rem 1rem;
background: #fffaf3;
border-bottom: 1px solid #e8dccb;
}Let's go through it from the top.
- --header-h is a CSS variable, a named value we can reuse. 3.5rem is 56 pixels at the default font size. We need the header's height in two other places, and a variable means we change it once.
- scroll-padding-top on html tells the browser: when you scroll something into view, for example after a click on #visit, leave this much room at the top. We leave the header's height plus 1rem of breathing space.
- position: sticky and top: 0 are the two lines that make it stick. top: 0 is the line from the magnet comparison: stick when the header's top edge reaches the top of the screen.
- z-index: 10 keeps the header above the content that scrolls under it. Remove it and some elements, like images or anything with its own position, will slide over your header instead of under it.
- display: flex with justify-content: space-between puts the logo on the left and the links on the right. flex-wrap: wrap lets the links drop onto a second line on a narrow phone instead of squeezing.
- gap: 0.25rem 1rem is a small vertical gap and a bigger horizontal gap, for when the links wrap.
- min-height, not height. The header can grow when the links wrap or when someone zooms the text. A fixed height would cut the text off.
- background is not decoration here. Without it the header is transparent, and the text scrolling underneath shows through the links. It's the most common first-try bug after the missing top.
The scroll-padding line deserves a closer look, because it fixes something most tutorials skip. Without it, a click on an anchor link scrolls the heading to the very top of the screen, which is exactly where your sticky header sits. The heading ends up hidden behind it. The scroll-padding-top reference describes it as a way to exclude regions of the scrollport "that are obscured by other content (such as fixed-positioned toolbars or sidebars)". That's our header.
It also matters for accessibility. When someone moves through a page with the Tab key, the browser scrolls each focused link into view, and the same padding keeps it from landing behind the header. WCAG 2.2, the accessibility web's main accessibility standard, has a level AA rule for this called Focus Not Obscured (Minimum): a focused element must not be entirely hidden by content the author created. The W3C's explanation names sticky headers as one of the typical causes. One line of CSS keeps you on the right side of it.
info
If you already use flexbox and grid comfortably, you'll notice the header is a flex row and not a grid. That's deliberate: it's one line of items whose widths come from their content. If you're unsure when to pick which, the flexbox versus grid article linked at the end walks through exactly that decision.
Why would you want the header to disappear at all? Good question. On a laptop, a 56 pixel header is a thin strip. On a phone held in landscape, the same header can take a big share of the screen, and while someone is reading down the page, they don't need your navigation. The moment they scroll up, even a little, they're probably looking for something, so the header comes back.
Before the code, the idea. The browser tells us how far the page is scrolled, as a number. We remember the last number. When a new one arrives, we compare: bigger means the reader is going down, smaller means up. Down, hide the header. Up, show it. That's the whole idea. Everything else in the script is about doing it politely.
const header = document.querySelector("[data-header]");
const THRESHOLD = 8;
let lastY = window.scrollY;
let ticking = false;
function update() {
const y = Math.max(window.scrollY, 0);
const delta = y - lastY;
if (Math.abs(delta) >= THRESHOLD) {
const goingDown = delta > 0;
const pastHeader = y > header.offsetHeight;
const focusInside = header.contains(document.activeElement);
header.classList.toggle("is-hidden", goingDown && pastHeader && !focusInside);
lastY = y;
}
ticking = false;
}
window.addEventListener("scroll", () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
}, { passive: true });
header.addEventListener("focusin", () => {
header.classList.remove("is-hidden");
});Line by line:
- const header finds the header through its data-header hook.
- THRESHOLD = 8 means we ignore movements smaller than 8 pixels. Without it, a shaky thumb on a phone would make the header flicker in and out. The number is a feel thing. Try 4 and 16 and pick what feels calm.
- lastY remembers where the page was the last time we made a decision. window.scrollY is how many pixels the page is scrolled from the top.
- ticking is a flag that says "an update is already planned". You'll see why in a moment.
- Inside update(), Math.max(window.scrollY, 0) protects us from negative numbers. On some phones, pulling the page down past the top bounces it, and the scroll position can be reported as below zero for a moment.
- delta is the difference between now and the last decision. If it's smaller than the threshold, we do nothing and keep lastY where it was, so small movements add up until they count.
- goingDown is true when delta is positive.
- pastHeader makes sure we never hide the header while the reader is still near the top of the page, where hiding it would just leave an empty strip.
- focusInside checks whether the keyboard focus is on one of the header's links. If it is, we never hide the header, because a keyboard user must be able to see where they are.
- classList.toggle("is-hidden", condition) adds the class when the condition is true and removes it when it's false. One line handles both directions.
- Then we store the new position in lastY and set ticking back to false.
Now the bottom half. The scroll event can fire many times while the page moves. If we did all our work in every single event, we'd be doing it more often than the screen can even show. So the listener only does one thing: if no update is planned yet, it plans one with requestAnimationFrame, which runs our function right before the browser draws the next frame. Ten scroll events between two frames lead to one update. That's what the ticking flag is for.
The { passive: true } option is a promise to the browser that this listener will never cancel the scroll. The browser can then scroll right away instead of waiting to see what our code wants. We never cancel scrolling here, so it's free speed.
The last listener shows the header again as soon as anything inside it receives focus. Picture someone who scrolled down with the arrow keys and then presses Shift and Tab to go back to the menu. Without this line, they'd be focused on a link that is hidden above the screen.
The script only adds and removes a class. The movement itself lives in CSS:
.site-header {
transition: transform 200ms ease-out;
}
.site-header.is-hidden {
transform: translateY(-100%);
}- transform: translateY(-100%) moves the header up by 100% of its own height. That's why we don't need to know the height in JavaScript. Whether it's 56 or 69 pixels, -100% is exactly enough to move it out of sight.
- transition: transform 200ms ease-out turns the jump into a short slide. ease-out starts fast and slows down at the end, which feels natural for something leaving the screen.
- In the real stylesheet the transition line sits in the same .site-header rule as the sticky lines from step 2. I split it out here so you can see it on its own.
Why move it with transform and not by changing top or height? Because changing height or top makes the browser recalculate where everything else on the page sits, on every frame of the animation. A transform moves the header's already painted picture without touching anything else. The web.dev guide to high-performance animations gives the same advice in plain words: stick to transform and opacity.
tip
Notice what happens if the script fails to load, or throws an error before it runs. Nothing breaks. The header just stays sticky and visible, because the class that hides it can only ever come from the script. That's called progressive enhancement: the page works without the extra, and the extra makes it nicer. Build every small effect this way.
Some people get dizzy or sick from movement on screens. Every major operating system has a setting for this, often called "Reduce motion", and the browser passes it to your CSS as the prefers-reduced-motion media feature. MDN explains that animations such as scaling or panning large objects can be vestibular motion triggers, which means they can upset the sense of balance. A header that slides across the whole width of the screen fits that description.
@media (prefers-reduced-motion: reduce) {
.site-header {
transition: none;
}
}- @media (prefers-reduced-motion: reduce) applies the rules inside only when the reader has turned on the reduced motion setting.
- transition: none removes the slide. The header still hides and comes back, but it does so instantly, with no movement in between.
Is that enough, or should the header stop hiding completely for these readers? Honestly, it depends on your page. An instant change is not motion, so I'm comfortable keeping the hiding. If your header is very tall, or your readers are likely to be sensitive to movement, the stricter choice is to skip the hiding entirely: read the same setting in JavaScript with window.matchMedia("(prefers-reduced-motion: reduce)").matches and don't attach the scroll listener.
For the record, the WCAG rule about this, Animation from Interactions, is level AAA, the strictest level, above the AA level most projects aim for. I'd still do it. It costs four lines, and the people it helps feel the difference immediately. The same goes for scroll-behavior: smooth, if you use it: put it inside a prefers-reduced-motion: no-preference block so smooth scrolling only happens for people who didn't ask for less motion.
Now the first screen under the header. The classic beginner hero is height: 100vh. The vh unit means one percent of the viewport height, so 100vh sounds like "exactly the screen". On a laptop, it is. On a phone, it often isn't, and the reason is the browser's address bar.
Mobile browsers show and hide their address bar and toolbars as you scroll. So which height is "the screen", with the bars or without them? CSS answers with three units, and the MDN reference for length units spells them out: svh is based on the small viewport, with the browser's bars showing, lvh on the large one, with them hidden, and dvh changes as the bars come and go. And here's the catch: "vh is equivalent to lvh". So 100vh is the height with the bars hidden. When the page first loads and the bars are showing, the bottom of your 100vh hero is hidden behind them. Usually that's exactly where the button is.
.hero {
display: grid;
place-items: center;
min-height: calc(100vh - var(--header-h));
min-height: calc(100svh - var(--header-h));
padding: 2rem 1.25rem;
}
.hero h1 {
margin: 0 0 0.75rem;
font-size: clamp(2rem, 1.25rem + 4vw, 3.75rem);
line-height: 1.1;
max-width: 18ch;
}
.hero p {
max-width: 45ch;
}- display: grid with place-items: center puts the hero's content in the middle, both ways, with two lines.
- There are two min-height lines on purpose. A browser that doesn't understand svh ignores the second line and keeps the first. A browser that does understand it uses the second, because the later rule wins. This is the oldest fallback trick in CSS, and it still works.
- 100svh minus the header's height means the header and the hero together fill the small viewport, so the button is visible on the first screen even with the address bar showing.
- min-height and not height, again. If the text is longer than you planned, or the reader zooms in, the hero grows instead of letting the text spill out of it.
- padding keeps the text off the screen edges on a phone.
The font size line is the one people copy without understanding, so let's take it apart. clamp() takes three values: a minimum, a preferred value and a maximum. The browser uses the preferred value, but never goes below the minimum or above the maximum.
- 2rem is the floor: 32 pixels at the default font size. The headline never gets smaller than that, even on the narrowest phone.
- 1.25rem + 4vw is the preferred size: a fixed 20 pixels plus 4% of the screen width. In the test runs it came out at 50.4 pixels on a 760 pixel wide screen, 35 pixels at 375 and 32.8 at 320.
- 3.75rem is the ceiling: 60 pixels. With this formula the headline reaches it at a screen width of 1,000 pixels and stops growing there, so it doesn't turn into a billboard on a big monitor.
- line-height: 1.1 tightens the lines. Big headlines with normal line spacing look like they're falling apart.
- max-width: 18ch limits the headline to roughly 18 characters per line. ch is the width of the zero character in the current font. Short lines make a big headline easier to read. The paragraph gets 45ch for the same reason.
Why the 1.25rem in the middle, and not just 6vw? Because of zoom. Text sized only in vw is tied to the window width, and zooming doesn't change how wide the window is in real terms, so the headline wouldn't grow when a reader zooms in. The rem part follows the reader's settings. WCAG's Resize Text rule asks that text can be resized up to 200 percent without losing content or function. Mixing rem into the formula keeps you on the safe side.
The trade-off nobody tells you about
Remember that the header is 56 pixels on a laptop and 69 on a phone, because the links wrap under the logo? Our hero subtracts --header-h, which is 56 pixels. So on that phone, the header and hero together are 13 pixels taller than the screen, and the very bottom of the hero sits just below the fold.
Is that a problem? Here, no. The hero's content is centred, so those 13 pixels are empty background, and nothing is cut off. But you should know it's there. If it ever matters, you have two honest options. You can design the phone header so the links never wrap, for example by moving them behind a menu button. Or you can measure the real header height in JavaScript with a ResizeObserver and write it into --header-h. The second one is more code to maintain for 13 pixels of background, so I wouldn't start there.
If your hero has an image
Our demo uses a gradient so it doesn't need any files. Most real heroes have a photo, and the photo is usually the biggest thing on the first screen. That makes it the element Google's loading metric, Largest Contentful Paint, is most likely to measure. So it's worth three attributes.
<img
class="hero-image"
src="/images/bread-on-rack.jpg"
alt="Loaves of sourdough cooling on a wooden rack"
width="1600"
height="900"
fetchpriority="high"
>- alt describes the photo for people who can't see it. Describe what's in it, not "hero image".
- width and height are the image's real size in pixels. The browser uses them to reserve the right amount of space before the file arrives, so the text doesn't jump down when the photo appears. Your CSS can still make it responsive with width: 100% and height: auto.
- fetchpriority="high" tells the browser this image matters more than the others, so it starts downloading it earlier.
- And notice what's missing: loading="lazy". Lazy loading is great for images further down the page, and harmful for the one at the top.
Both of those last points have measurements behind them. The web.dev team measured that lazy loading images inside the first screen made Largest Contentful Paint worse, in their article on the performance effects of too much lazy loading, and their Fetch Priority guide shows fetchpriority="high" on the main image as the standard move.
Here's the finished page: the header from steps 1 and 2, the hiding script from step 3, the reduced motion rule from step 4 and the hero from step 5. Scroll down slowly inside the demo and watch the header slide away. Scroll up a little and it comes back. Click Menu, Visit or Order and see where the headings land. Then click inside the demo and press Tab a few times.
<a class="skip" href="#main">Skip to content</a>
<header class="site-header" data-header>
<a class="logo" href="#top">Harbor Lane Bakery</a>
<nav aria-label="Main">
<ul>
<li><a href="#menu">Menu</a></li>
<li><a href="#visit">Visit</a></li>
<li><a href="#order">Order</a></li>
</ul>
</nav>
</header>
<main id="main">
<section class="hero" id="top" aria-labelledby="hero-title">
<div class="hero-text">
<h1 id="hero-title">Fresh bread, baked before you wake up</h1>
<p>Sourdough, rye and cinnamon rolls from one small oven. Order by 8 pm, pick up at 7 am.</p>
<a class="button" href="#order">Order for tomorrow</a>
<label class="calm"><input type="checkbox" data-calm> Pretend I asked for reduced motion</label>
</div>
</section>
<section class="block" id="menu">
<h2>Menu</h2>
<p>Scroll down slowly and watch the header slide away. Then scroll up a little and it comes back. That is the whole trick.</p>
<p>Emily bakes the sourdough. James does the rye. Olivia gets in first and does the cinnamon rolls, because somebody has to.</p>
<p>Keep scrolling. There is more text here only so the page is long enough to scroll.</p>
</section>
<section class="block" id="visit">
<h2>Visit</h2>
<p>Click "Menu", "Visit" or "Order" in the header. Each heading lands below the header instead of hiding under it. That is scroll-padding-top doing its job.</p>
<p>Now press Tab until the focus reaches the header links. The header comes back, because a keyboard user has to see where the focus is.</p>
</section>
<section class="block" id="order">
<h2>Order</h2>
<p>Orders close at 8 pm. Pick up at the counter from 7 am.</p>
<p>This is the end of the demo. Scroll back up to the top.</p>
</section>
</main>:root {
--header-h: 3.5rem;
}
html {
scroll-padding-top: calc(var(--header-h) + 1rem);
}
body {
padding: 0;
background: #fffaf3;
color: #2b2118;
}
.skip {
position: absolute;
left: 1rem;
top: -3rem;
z-index: 20;
padding: 0.5rem 0.75rem;
background: #2b2118;
color: #fff;
}
.skip:focus {
top: 0.5rem;
}
.site-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.25rem 1rem;
min-height: var(--header-h);
padding: 0.5rem 1rem;
background: #fffaf3;
border-bottom: 1px solid #e8dccb;
transition: transform 200ms ease-out;
}
.site-header.is-hidden {
transform: translateY(-100%);
}
@media (prefers-reduced-motion: reduce) {
.site-header {
transition: none;
}
}
.calm-mode .site-header {
transition: none;
}
.logo {
font-weight: 700;
color: inherit;
text-decoration: none;
}
nav ul {
display: flex;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
nav a {
color: #8a3b12;
}
.hero {
display: grid;
place-items: center;
min-height: calc(100vh - var(--header-h));
min-height: calc(100svh - var(--header-h));
padding: 2rem 1.25rem;
background: linear-gradient(160deg, #f6e3c8, #fffaf3 70%);
}
.hero-text {
max-width: 40rem;
}
.hero h1 {
margin: 0 0 0.75rem;
font-size: clamp(2rem, 1.25rem + 4vw, 3.75rem);
line-height: 1.1;
max-width: 18ch;
}
.hero p {
max-width: 45ch;
}
.button {
display: inline-block;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
background: #8a3b12;
color: #fff;
text-decoration: none;
}
.calm {
display: block;
margin-top: 1rem;
font-size: 0.875rem;
}
.block {
max-width: 40rem;
margin: 0 auto;
padding: 2rem 1.25rem 6rem;
}const header = document.querySelector("[data-header]");
const THRESHOLD = 8;
let lastY = window.scrollY;
let ticking = false;
function update() {
const y = Math.max(window.scrollY, 0);
const delta = y - lastY;
if (Math.abs(delta) >= THRESHOLD) {
const goingDown = delta > 0;
const pastHeader = y > header.offsetHeight;
const focusInside = header.contains(document.activeElement);
header.classList.toggle("is-hidden", goingDown && pastHeader && !focusInside);
lastY = y;
}
ticking = false;
}
window.addEventListener("scroll", () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
}, { passive: true });
header.addEventListener("focusin", () => {
header.classList.remove("is-hidden");
});
document.querySelector("[data-calm]").addEventListener("change", (event) => {
document.body.classList.toggle("calm-mode", event.target.checked);
});Two things in the full source you haven't seen yet. The skip link is positioned above the screen with top: -3rem and comes down to top: 0.5rem when it receives focus, so keyboard users see it and everyone else doesn't. And the checkbox called "Pretend I asked for reduced motion" exists only in this demo. It adds a class to the body that removes the transition, so you can feel the difference without changing your system settings. In a real project, the media query from step 4 does that job.
Try one more thing: click Visit while you're at the top. The page jumps down, so the script reads it as scrolling down and hides the header. The heading lands with a little extra space above it, because scroll-padding reserved room for a header that has just left. Jump back up with the logo and the header returns. I left that behaviour in on purpose. It's correct, and it's the kind of detail you should notice when you test your own pages.
Look at what we actually wrote. The sticky header is two lines, position: sticky and top: 0, plus the knowledge that it can only move inside its parent and that an overflow value on any ancestor can steal it. The hiding header is a comparison of two numbers and a class. The slide is one transform. Reduced motion is four lines. The hero is min-height with svh and a font size with clamp().
No library, no framework, nothing to install. When you see a polished header on a big website, it's very likely this, plus good design. The tricks are small. Knowing which small trick solves which problem is the actual skill.
A good programmer thinks a few steps ahead. Here's what tends to go wrong once a header like this meets a real site and real people.
- Something else is sticky too. A cookie banner at the bottom, a promotion bar above the header, a sticky table of contents. Each one eats screen space and each one can cover a focused link. Count your sticky things on a phone in landscape before you ship.
- A wrapper you didn't write. Frameworks and page builders love to wrap your page in divs, and some of those divs carry overflow: hidden. Run the DevTools snippet from earlier before you rewrite your header.
- A huge header. If your header holds a search field, a promotion line and two rows of links, sliding it away on scroll is less of a nice touch and more of a necessity. Sometimes the better fix is a smaller header.
- The header hides while someone is using it. Our script never hides it while focus is inside, but if your header has a dropdown menu, also keep it visible while the dropdown is open.
- Very short screens. A phone in landscape can be under 400 pixels tall. Even a modest sticky header is a big slice of that.
For that last case, here's a small Plan B that turns the sticky behaviour off when the screen is very short:
@media (max-height: 30rem) {
.site-header {
position: static;
}
}- max-height: 30rem matches screens up to 480 pixels tall at the default font size, which covers most phones held sideways.
- position: static is the default positioning, so the header scrolls away with the page like any normal element and gives the whole screen back to the content.
- Our script can keep running. It still adds the class, but a header that has already scrolled out of view doesn't care.
In the test for this article, the header scrolled away with the page in a window 400 pixels tall and stayed sticky in one 600 pixels tall. And if your plan B for all of this is "hope nobody uses a phone sideways", that's not a plan B.
warning
Don't copy a hide-on-scroll script that listens to the scroll event and changes top or height directly on every event. It works on a fast laptop and stutters on a cheap phone, which is where most of your readers are. Plan the update with requestAnimationFrame, move the header with transform, and mark the listener as passive.
A nice finishing touch is a soft shadow under the header, but only once it's actually stuck. At the very top of the page the header sits on the hero and a shadow would look odd. CSS alone has long had no way to ask "is this sticky element stuck right now", so the usual trick is a sentinel: an invisible one-pixel element placed right above the header. When the sentinel scrolls out of view, the header must be stuck.
<div class="header-sentinel" aria-hidden="true"></div>
<header class="site-header">Harbor Lane Bakery</header>const sentinel = document.querySelector(".header-sentinel");
const stickyHeader = document.querySelector(".site-header");
const observer = new IntersectionObserver(([entry]) => {
stickyHeader.classList.toggle("is-stuck", !entry.isIntersecting);
});
observer.observe(sentinel);- The sentinel is an empty div, one pixel tall in CSS, with aria-hidden="true" so screen readers skip it. It sits directly before the header.
- IntersectionObserver is a browser feature that tells you when an element enters or leaves the visible area. You don't listen to scroll events at all; the browser does the watching for you.
- The callback receives a list of entries. We watch only one element, so we take the first one with [entry].
- entry.isIntersecting is true while the sentinel is visible. When it's not visible, the page has scrolled past it, the header is stuck, and we add the is-stuck class. A rule like .site-header.is-stuck { box-shadow: 0 2px 8px rgb(0 0 0 / 0.12); } draws the shadow.
In the test, the class was off at the top, on after scrolling 300 pixels, and off again after scrolling back. The Intersection Observer API reference is worth reading in full if you haven't used it. It's the same tool behind lazy loading, infinite lists and "mark as read" features.
There is also a newer, CSS-only way. Container scroll-state queries let you style an element based on whether a sticky container is stuck, with no JavaScript at all. Newer CSS features often arrive in one browser first, so check the compatibility table on that page before you rely on it, and keep the sentinel as the fallback.
One last note on performance for the hiding script. We read window.scrollY and header.offsetHeight inside a requestAnimationFrame callback, and we write only a class. Reading layout values and then writing styles in the same frame, again and again, is what makes scroll effects stutter. Read first, write once, and the browser has an easy job.
If the flex row in the header made you wonder when to use flexbox and when grid, Flexbox vs Grid: How to Choose, on Real Layouts answers exactly that on real page pieces. The next section on most landing pages is a pricing table, and Build a Pricing Table From Scratch rebuilds that one the same way we rebuilt the header today.
Our three navigation links were written by hand. On a real site they often come from a list of data, and One Card, Many Posts: Render Lists Without Repeating Code shows how to write one template and let the data fill it. And if you're right at the start and some of today's words were new, What Is Web Development? A Complete Beginner's Guide puts HTML, CSS and JavaScript into one picture.
Then practise. Pick a website you use every day, look at its header and its first screen, and rebuild the pattern with your own text and colours. Test it at 320 pixels wide, with the keyboard only, and with reduced motion switched on. When all three feel right, you've learned more than any list of CSS properties could teach you.
You can do this, and you have to. If you weren't capable, I wouldn't spend my time on you. Just don't give up, and you'll always have me on your side.
