A sticky header can make your site feel faster and easier to navigate, since your logo and menu stay within reach as you scroll. I will show you how to create a sticky header that changes on scroll, first with a quick example, then with a complete, responsive version. I will explain every step in plain language, so even if you are new to CSS and JavaScript, you can follow along and test it on your own.
What a “sticky header” is, and why it should change on scroll
A sticky header is a top bar that sticks to the top of the page as you scroll down. In CSS, this is usually done with position: sticky plus top: 0. Unlike position: fixed, a sticky element stays in the normal page flow until you scroll past it, then it sticks. I like to change the header’s background, height, and shadow once the user scrolls a little, which improves readability over the content and makes the header feel subtle at the top, then more solid as you move down.
Key CSS for the sticky behavior looks like this:
.header {
position: sticky;
top: 0; /* stick to top when reached */
z-index: 1000; /* keep it above page content */
}
To “change on scroll,” I add or remove a class on the header when the page scroll position is greater than a small threshold. For example:
header.classList.toggle('scrolled', window.scrollY > 10);
This flips the .scrolled class on the header element. Then I can define different styles for .header.scrolled in CSS.
Example 1: Sticky header that changes on scroll
This first example uses mostly a single div for the header, minimal CSS, and a tiny script that adds a shadow, a darker background, and slightly reduces padding once you scroll. Copy, paste, and run it in your browser.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Quick Sticky Header That Changes on Scroll</title>
<style>
:root {
--text: #111827;
--text-inverse: #ffffff;
--bg: rgba(255, 255, 255, 0.7);
--bg-scrolled: #111827;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
line-height: 1.6;
color: var(--text);
}
/* Sticky header */
.header {
position: sticky;
top: 0;
z-index: 1000;
background: var(--bg);
color: var(--text);
backdrop-filter: saturate(180%) blur(8px);
padding: 16px 20px;
transition:
background-color 200ms ease,
color 200ms ease,
box-shadow 200ms ease,
padding 200ms ease;
}
/* Style once scrolled */
.header.scrolled {
background: var(--bg-scrolled);
color: var(--text-inverse);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
padding: 10px 20px;
}
.title { font-weight: 700; font-size: 18px; }
/* Page content */
.content { padding: 24px 20px; max-width: 800px; margin: 0 auto; }
.block {
height: 85vh;
margin-bottom: 24px;
padding: 20px;
border-radius: 12px;
background: linear-gradient(180deg, #fdf2f8, #eff6ff);
}
@media (max-width: 480px) {
.title { font-size: 16px; }
}
</style>
</head>
<body>
<div class="header" id="header">
<div class="title">My Sticky Header</div>
</div>
<main class="content">
<div class="block">Scroll to see the header change, background darkens and padding shrinks.</div>
<div class="block">Keep scrolling for more space to test the behavior.</div>
<div class="block">End of demo.</div>
</main>
<script>
(function () {
const header = document.getElementById('header');
const onScroll = () => header.classList.toggle('scrolled', window.scrollY > 10);
onScroll(); // initialize on load
window.addEventListener('scroll', onScroll, { passive: true });
})();
</script>
</body>
</html>
A couple of important lines:
- CSS
position: sticky; top: 0;makes the header stick. - JavaScript
header.classList.toggle('scrolled', window.scrollY > 10);adds thescrolledclass once you scroll farther than 10 pixels. - The
.header.scrolledrule sets a darker background and a shadow for contrast.
If your content ever overlaps the header, increase z-index on .header or check that no parent has overflow: hidden that can trap the sticky element.
Example 2: Complete responsive sticky header that changes on scroll
This full example adds a logo, a navigation menu, a call to action button, and a mobile menu that slides down. The header changes background, height, logo size, and shadow when you scroll. It uses IntersectionObserver for a smooth, efficient scroll detection, with a tiny fallback to scroll if needed.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Complete Sticky Header That Changes on Scroll</title>
<style>
:root {
--brand: #2563eb;
--text: #111827;
--text-dim: #374151;
--bg-scrolled: #ffffff;
--header-height: 72px;
--header-height-scrolled: 56px;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color: var(--text);
line-height: 1.6;
}
/* Sticky site header */
.header {
position: sticky;
top: 0;
z-index: 1000;
background: rgba(255, 255, 255, 0.6);
backdrop-filter: saturate(180%) blur(8px);
transition: background-color 200ms ease, box-shadow 200ms ease;
}
.header-inner {
max-width: 1100px;
margin: 0 auto;
height: var(--header-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 16px;
transition: height 200ms ease;
}
.header.is-scrolled {
background: var(--bg-scrolled);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
}
.header.is-scrolled .header-inner {
height: var(--header-height-scrolled);
}
/* Brand */
.brand {
display: inline-flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: var(--text);
font-weight: 800;
letter-spacing: 0.2px;
}
.logo {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, var(--brand), #7c3aed);
display: grid;
place-items: center;
color: #fff;
font-size: 18px;
transition: transform 200ms ease;
user-select: none;
}
.header.is-scrolled .logo {
transform: scale(0.9);
}
/* Navigation */
.nav {
display: flex;
align-items: center;
gap: 12px;
}
.nav a {
text-decoration: none;
color: var(--text-dim);
padding: 8px 10px;
border-radius: 8px;
transition: background-color 150ms ease, color 150ms ease;
}
.nav a:hover {
background: rgba(37, 99, 235, 0.08);
color: var(--text);
}
.cta {
background: var(--brand);
color: #fff;
border: none;
padding: 10px 14px;
border-radius: 10px;
cursor: pointer;
font-weight: 600;
}
/* Mobile menu button */
.menu-btn {
display: none;
background: transparent;
border: 0;
padding: 8px 10px;
border-radius: 8px;
font-size: 16px;
}
/* Content */
main { max-width: 1100px; margin: 0 auto; padding: 16px; }
.hero {
height: 60vh;
margin: 16px;
border-radius: 16px;
display: grid;
place-items: center;
text-align: center;
background: linear-gradient(135deg, #e0f2fe, #f5f3ff);
}
.section {
max-width: 900px;
margin: 0 auto;
padding: 24px 16px;
}
.section + .section { border-top: 1px solid #e5e7eb; }
.footer {
border-top: 1px solid #e5e7eb;
color: #6b7280;
text-align: center;
padding: 24px 16px;
}
/* Mobile layout */
@media (max-width: 768px) {
.menu-btn { display: inline-flex; align-items: center; gap: 8px; }
.nav {
position: fixed;
inset: var(--header-height) 0 auto 0; /* top, right, bottom, left */
background: rgba(255, 255, 255, 0.98);
transform: translateY(-110%);
opacity: 0;
pointer-events: none;
transition: transform 220ms ease, opacity 220ms ease;
padding: 16px;
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.08);
flex-direction: column;
gap: 8px;
}
.header.is-scrolled .nav {
inset: var(--header-height-scrolled) 0 auto 0;
}
.nav.open {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.nav a { font-size: 18px; }
.cta { width: 100%; text-align: center; }
}
/* Nice anchor jumps under a sticky header */
:where(h2, h3, section, [id]) { scroll-margin-top: var(--header-height-scrolled); }
</style>
</head>
<body>
<header class="header" id="site-header">
<div class="header-inner">
<a class="brand" href="#">
<div class="logo" aria-hidden="true">S</div>
<span>StickySite</span>
</a>
<button class="menu-btn" id="menu-btn" aria-expanded="false" aria-controls="site-nav">
Menu
</button>
<nav class="nav" id="site-nav">
<a href="#features">Features</a>
<a href="#pricing">Pricing</a>
<a href="#docs">Docs</a>
<button class="cta">Get Started</button>
</nav>
</div>
</header>
<main>
<div class="hero">
<div>
<h1>Sticky Header That Changes on Scroll</h1>
<p>Scroll down to watch the header background, height, and shadow adapt.</p>
</div>
</div>
<section class="section" id="features">
<h2>Features</h2>
<p>Readable at any scroll position, smooth transitions, mobile menu, and clean CSS variables for sizes and colors.</p>
<p>Keep scrolling to trigger the header change.</p>
</section>
<section class="section" id="pricing">
<h2>Pricing</h2>
<p>Use this space to test anchor links under a sticky header, the page offsets correctly using scroll-margin-top.</p>
</section>
<section class="section" id="docs">
<h2>Docs</h2>
<p>Replace this content with your documentation, or keep it as a scroll testing area.</p>
</section>
</main>
<footer class="footer">Footer area</footer>
<script>
(function () {
const header = document.getElementById('site-header');
const menuBtn = document.getElementById('menu-btn');
const nav = document.getElementById('site-nav');
// Mobile menu toggle
menuBtn.addEventListener('click', () => {
const open = nav.classList.toggle('open');
menuBtn.setAttribute('aria-expanded', String(open));
});
// Helper to toggle scrolled class
const update = (scrolled) => header.classList.toggle('is-scrolled', scrolled);
// Prefer IntersectionObserver for performance
if ('IntersectionObserver' in window) {
const sentinel = document.createElement('div');
sentinel.style.position = 'absolute';
sentinel.style.top = '0';
sentinel.style.height = '1px';
sentinel.style.width = '1px';
document.body.prepend(sentinel);
const obs = new IntersectionObserver((entries) => {
// When sentinel leaves the viewport, user has scrolled
update(!entries[0].isIntersecting);
});
obs.observe(sentinel);
// Set initial state
requestAnimationFrame(() => update(window.scrollY > 10));
} else {
// Fallback
const onScroll = () => update(window.scrollY > 10);
onScroll();
window.addEventListener('scroll', onScroll, { passive: true });
}
})();
</script>
</body>
</html>
Output:

A few details I want you to notice:
- The header sticks with
position: sticky; top: 0;so content naturally flows below it. - I change the header’s height by targeting
.header.is-scrolled .header-inner. That keeps the transition smooth. - The mobile menu is
position: fixedinside the header, it slides in under the header usinginsetand a transform, then it adjusts its top offset when the header is scrolled.
To highlight the core scroll logic, here is the IntersectionObserver line that flips state based on a tiny sentinel at the top of the page:
new IntersectionObserver(entries => update(!entries[0].isIntersecting));
This is both smooth and battery friendly, since the browser can batch these observations.
Common pitfalls and quick fixes
- Header covering anchor links: add
scroll-margin-topto sections, for examplescroll-margin-top: 56px;so in page links land cleanly below the header. - Header not staying on top: ensure a high
z-indexon the header, and avoidoverflow: hiddenon a parent wrapper. - Low contrast after scroll: add a solid background and
box-shadowto.is-scrolledso text remains legible over complex content.
Closing thoughts
You now have two working patterns for a sticky header that changes on scroll. The quick version is perfect for prototypes or simple sites, the complete version gives you a solid base for production with responsive behavior and smooth performance. As you refine your design, play with the transition timing, the shadow intensity, and the color contrast so the header feels helpful, not distracting.


