Skip to content
All posts
Engineering/February 14, 2026/Omar Majed

Building a Scroll-Driven Header Glassmorphism Effect (Zero JavaScript)

Your sticky header has a scroll listener. Mine has three lines of CSS. We are not the same. A tutorial on animation-timeline: scroll() and why your header's JavaScript deserves a permanent vacation.

Vibrant abstract glass texture with defocused colorful light, illustrating glassmorphism and blur effects

It is a Tuesday. You are building a sticky header. The designer wants it transparent at the top of the page, then frosted glass with a shadow after the user scrolls. Simple, right? You open your component file and write the sacred incantation:

unknown node

And just like that, you have summoned it. The beast. The scroll listener. It arrives with its entourage: state, an effect, a cleanup function you will forget the first time, and a conditional className ternary longer than your commit messages. Eighteen lines of JavaScript to change a background color. The engineering equivalent of hiring a moving company to slide a couch six inches to the left.

Welcome to The Scroll Listener Graveyard — a haunted place where good intentions become performance problems. Every frontend codebase has one. I have personally contributed to at least seven. And I am here to tell you: it is time to stop digging graves.

Three Lines of CSS That Mass-Retired an Industry of Hooks

Here is what we actually ship in globals.css. The entire scroll effect. All of it:

unknown node

Three properties. No hooks. No refs. No state. No cleanup. Just CSS telling the browser: "You are already tracking scroll position for the scrollbar. Use that same data to drive this animation. Thanks. Bye."

Let us break it down. animation-timeline: scroll() is the star. Instead of time driving the animation, scroll position drives it. Scroll to 25% of the range? Animation is at 25%. Scroll back up? It reverses. The browser's compositor thread handles everything — your main thread is on vacation. animation-range: 0px 50px defines the scroll window: the full transition completes within the first 50 pixels. Anything beyond that holds at 100%. The header commits to being frosted and does not look back.

The Keyframes: Five Properties, One Glow-Up

Here is the keyframe from our globals.css that powers the glassmorphism:

unknown node

Five properties animating in concert. The header starts transparent and tall, then gains a semi-transparent background, 24px of frosted blur, a shadow, a border, and shrinks from 80px to 64px. Notice the 50%, 100% trick — the animation completes at the halfway point and then plateaus. With a 50px range, the full visual transition happens in 25 pixels. Snappy. Confident. The header says "you scrolled, I am frosted now, deal with it."

The real magic is oklch(from var(--background) l c h / 0.8). This relative color syntax takes whatever the current theme's background is — white, dark gray, warm amber, any of our 40+ color schemes — extracts its OKLCH channels, and applies 80% opacity. One expression. Every theme. Zero JavaScript color parsing. The border gets the same treatment with oklch(from var(--border) l c h / 0.5). Theme changes propagate through CSS variables and the glassmorphism follows along like a well-trained puppy that runs on the compositor thread.

The Component: Blissfully Ignorant

Here is the actual <header> element from our header.tsx:

unknown node

One CSS class: header-scroll. That is the entire integration. No onScroll, no scrollY state, no useScrollPosition hook, no isScrolled && 'bg-background/80 backdrop-blur-xl' ternary. The component is blissfully, beautifully ignorant of the scroll position. Compare that to what the JavaScript approach would require:

unknown node

Fifteen lines of JavaScript, a state variable, an effect with cleanup, a requestAnimationFrame wrapper, and a className ternary that makes your linter cry. Or three lines of CSS. I know which one I would pick. The Scroll Listener Graveyard just got its last resident.

Performance: Main Thread vs. Compositor Thread

The JavaScript approach fires on the main thread sixty times per second. On a mid-range Android phone with thirty-seven tabs open? You just introduced jank. Congratulations — you are now the reason someone's phone felt slow.

Scroll-driven animations run on the compositor thread — the browser's GPU-accelerated layer. Your React components do not re-render. Your state does not update. I profiled both approaches: the JavaScript version showed periodic main thread spikes. The CSS version? Flat line. The main thread was asleep. Dreaming of a world where it never processes another scroll event.

Browser Support (The Obligatory Section)

As of early 2026: Chrome 115+, Edge 115+, Firefox 110+, Safari 18.4+. That covers roughly 92% of global usage. For the rest, the animation simply does not run and the header stays transparent. The site is fully functional. This is textbook progressive enhancement — no fallback JavaScript required. With a JS scroll listener, failure mode is "who knows." With CSS, failure mode is "no animation." I know which bug report I prefer at 3am.

The Bottom Line (Scrolled All the Way Down Edition)

For years we accepted that scroll-based effects required JavaScript. CSS scroll-driven animations changed the game. Three properties. Zero JavaScript. Compositor-thread performance. Automatic reverse on scroll-up. Theme-aware through OKLCH relative color syntax. Progressive enhancement by default.

So the next time your designer asks for a sticky header that fades to frosted glass on scroll, do not reach for addEventListener. Reach for CSS. Write three lines. Ship it. The Scroll Listener Graveyard is closed for good. Long live scroll-driven CSS.

CSSAnimationPerformanceFrontend