Sticky Header in Tailwind: Scroll-Aware Transparent-to-Solid Nav
Build a scroll-aware sticky header in Tailwind CSS that transitions from transparent to solid — no JS library needed, just a scroll listener and class swap.
Why Transparent-to-Solid Headers Feel So Good
You've seen it a thousand times — the nav starts fully transparent over a hero image, the user scrolls 60px down, and it snaps into a frosted or solid bar that stays pinned. It's one of those small details that separates a polished product from a bare template. And the good news: you don't need Framer Motion or a bloated scroll library to pull it off.
The core idea is dead simple. You track scroll position with a tiny JS listener, toggle a CSS class on the <header>, and Tailwind handles the visual transition. That's it. No useEffect with complex cleanup, no third-party dependency, just a scrollY check and a class swap.
Honestly, most tutorials overcomplicate this. They reach for IntersectionObserver on a sentinel div or wire up a full state-management solution for something a 10-line function can handle. Let's keep it grounded.
Worth noting: if you're using a glassmorphism navbar design pattern — think backdrop blur, semi-transparent background — the same technique applies. The scroll class just switches from bg-transparent to bg-white/80 backdrop-blur-md instead of a fully opaque color.
The HTML Structure You're Working With
Before touching scroll behavior, get the markup right. Your header needs sticky top-0 z-50 — not fixed. People confuse these. fixed pulls the element out of normal flow entirely and can cause layout shifts for content below it. sticky keeps it in flow until it reaches the top, then pins it. Way less headache.
Here's a clean starting point:
``html
<header
id="site-header"
class="sticky top-0 z-50 w-full transition-all duration-300"
>
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<a href="/" class="text-xl font-bold">Logo</a>
<nav class="flex gap-6 text-sm font-medium">
<a href="/about">About</a>
<a href="/blog">Blog</a>
<a href="/pricing">Pricing</a>
</nav>
</div>
</header>
``
The transition-all duration-300 is key — without it, the background color switch is a jarring instant flash. 300ms feels natural. You could push it to 400ms if you want buttery, but anything over 500ms starts feeling sluggish.
Quick aside: z-50 maps to z-index: 50 in Tailwind's default scale. If you've got modals or dropdowns in your app, you might need z-[100] or higher on those elements. Don't forget to audit your stacking context before shipping.
The Scroll Listener — Keep It Lean
Here's the JavaScript. No React, no Vue, just vanilla — because this pattern works everywhere and you can port it to any framework in 30 seconds:
``js
const header = document.getElementById('site-header');
const SCROLL_THRESHOLD = 80; // px
window.addEventListener('scroll', () => {
if (window.scrollY > SCROLL_THRESHOLD) {
header.classList.add('scrolled');
} else {
header.classList.remove('scrolled');
}
}, { passive: true });
``
The { passive: true } option tells the browser this listener won't call preventDefault(), so it can optimize scrolling performance. Always add this for scroll listeners — it's been the right call since Chrome 51 landed passive event support back in 2016.
The threshold of 80px is deliberate. It's roughly the height of a typical hero section's top padding. You want the transition to happen after the user has clearly started scrolling, not on a 2px accidental drag. Adjust to match your hero.
In React, you'd drop this in a useEffect inside your layout component:
``tsx
import { useEffect } from 'react';
export function SiteHeader() {
useEffect(() => {
const header = document.getElementById('site-header');
const onScroll = () => {
header?.classList.toggle('scrolled', window.scrollY > 80);
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
return (
<header
id="site-header"
className="sticky top-0 z-50 w-full transition-all duration-300"
>
{/* nav content */}
</header>
);
}
``
Notice the cleanup in the return — that removeEventListener call matters. Skip it and you'll leak event listeners every time the component remounts, which in strict mode will happen immediately in development.
Wiring the Tailwind Classes for the Transition
Now for the CSS side. You've got the scrolled class toggling on the header. You need to define what that class actually does. Two approaches: extend your Tailwind config with a plugin, or just write a small CSS block. I reach for plain CSS here — no reason to overcomplicate it:
``css
/* globals.css or your base stylesheet */
#site-header {
background: transparent;
color: white;
}
#site-header.scrolled {
@apply bg-white shadow-md;
color: #111;
}
``
If you want the glassmorphism variant that's everywhere in 2026 — semi-transparent with blur — swap that @apply line:
``css
#site-header.scrolled {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
@apply shadow-sm;
}
``
The -webkit-backdrop-filter prefix still matters for Safari. Yes, in 2026. Safari's WebKit still requires it — skip it and iOS users get a jarring opaque background instead of the blur. You can try the glassmorphism generator to preview and copy these values without doing mental math on blur radius and opacity.
In practice, I prefer the blur variant for dark hero sections and the solid white for light-background sites. Dark hero + opaque white header on scroll looks sharp. Light background + transparent header doesn't read well at all since there's no contrast to start with.
That said, if you're building a dark-mode-first app, flip those colors. bg-gray-900/90 with backdrop-blur-lg works beautifully for dark UIs. Check how we approached it in the glassmorphism components section for inspiration.
Handling Logo and Text Color Changes
Here's where people get tripped up. Your transparent nav has white text (to read over a dark hero image). After scroll, the header goes white — now your white text is invisible. You need to handle that color flip too.
One clean approach: CSS custom properties on the header element. Toggle a data-scrolled attribute instead of a class, and let CSS variables do the heavy lifting:
``css
#site-header {
--nav-text: #ffffff;
--nav-bg: transparent;
background: var(--nav-bg);
color: var(--nav-text);
transition: background 300ms ease, color 300ms ease;
}
#site-header[data-scrolled='true'] {
--nav-text: #111111;
--nav-bg: rgba(255, 255, 255, 0.92);
}
.nav-link {
color: var(--nav-text);
}
.nav-logo path {
fill: var(--nav-text);
}
``
The SVG logo fill trick is key. If your logo is an inline SVG, applying fill: var(--nav-text) means it automatically flips from white to dark as the header transitions. No JavaScript needed for the color swap — CSS handles it through the same transition declaration.
For the JavaScript side, just toggle data-scrolled instead of a class:
``js
window.addEventListener('scroll', () => {
const scrolled = window.scrollY > 80;
header.dataset.scrolled = String(scrolled);
}, { passive: true });
``
This pattern also plays nicely with Tailwind's arbitrary variants. In Tailwind v3.4+ you can do data-[scrolled=true]:bg-white data-[scrolled=true]:text-gray-900 directly in your JSX if you'd rather avoid the separate CSS file entirely.
Mobile Nav: Same Trick, Different Problem
Mobile breaks the transparent header pattern for one reason: your hamburger icon. White icon on transparent dark hero is fine. But if you open the mobile menu and the menu background is white, you've got a white icon on a white panel. Fix it early.
The simplest solution: let the mobile menu open state force the scrolled appearance regardless of scroll position. In React with a useState hook:
``tsx
const [menuOpen, setMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const isScrolled = scrolled || menuOpen;
``
Then use isScrolled to drive your header class. When the menu opens, the header goes solid immediately — no awkward half-transparent panel over your hero. Close the menu and if you're back at the top, it goes transparent again.
One more thing — test this on iOS Safari at 375px wide. That's still the most common mobile viewport in the US market. The sticky positioning behavior can be quirky in Safari if any parent element has overflow: hidden set. If your header stops sticking on mobile, that's almost always the culprit — audit your layout wrappers.
Performance and Accessibility Considerations
Scroll listeners are cheap when done right. The passive: true flag, covered earlier, handles the main perf concern. But you should also avoid reading layout properties (offsetHeight, getBoundingClientRect) inside the listener itself — those force layout recalculation on every scroll event. window.scrollY is safe, it's a simple property read.
If you're paranoid about frame rate (and you should be on lower-end Android devices), wrap the listener with requestAnimationFrame:
``js
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
header.dataset.scrolled = String(window.scrollY > 80);
ticking = false;
});
ticking = true;
}
}, { passive: true });
``
For accessibility: your sticky header needs role="banner" if it's the primary site header — it usually already has this semantically as a <header> element at the top level. More importantly, make sure focus management isn't broken. When a keyboard user tabs past the header, the sticky positioning shouldn't cause focused elements to be obscured by the header. Use scroll-padding-top on the <html> element equal to your header height:
``css
html {
scroll-padding-top: 72px; /* match your header height */
}
``
Why does this matter? When a user activates a skip-to-content link or an anchor jumps them down the page, the browser's scroll position needs to account for the sticky header's 72px (or whatever your height is) or the heading they're jumping to will be hidden under the nav. This is a WCAG 2.1 failure if you skip it.
Want to see how these navigation patterns fit into a full UI system? The navbar patterns in React article covers component structure, and if you need pre-built components to start from, browse the Empire UI component library for production-ready navbars you can customize.
FAQ
sticky keeps the element in the document flow until it hits the scroll boundary, then pins it — avoids layout shifts. fixed removes it from flow entirely, meaning you need to add margin-top to the next element to prevent content jumping under the nav.
That's the classic sticky gotcha. position: sticky is scoped to its scroll container — if any ancestor has overflow: hidden or overflow: auto, the element treats that as its scroll boundary, not the viewport. Remove or change the overflow property on the offending parent.
Run your scroll check once on mount before attaching the listener: if (window.scrollY > 80) header.dataset.scrolled = 'true'; — this handles the case where someone refreshes partway down the page and the header would otherwise briefly appear transparent over the wrong background.
Not with the scrollY threshold approach — CSS has no way to read scroll position yet (CSS scroll-driven animations get close but can't toggle arbitrary classes). You need at minimum a tiny scroll listener. The CSS-only workaround using position: sticky combined with ::before pseudo-elements is too hacky for production.