← blog
CSSOctober 27, 2026 · 5 min read

Stop Writing JS to Detect a Stuck Header. scroll-state() Fixes It.

A sticky header that should only cast a shadow once it's actually stuck to the top usually means a hidden sentinel div and an IntersectionObserver. CSS now tracks that state itself — the `scroll-state()` container query reads it directly, no sentinel, no observer, no JS math.

Parsa Jiravand · Frontend engineer · building bestpractic
Stop Writing JS to Detect a Stuck Header. `scroll-state()` Fixes It.

The brief was one sentence: "the header should get a shadow once it's stuck to the top." Not while it's visible — only once it's actually locked there, mid-scroll, floating over the content instead of sitting in it. Someone had already built it, eight months earlier, and it had quietly been wrong the whole time: the shadow appeared the instant the header scrolled into view, a good 40 pixels before it ever touched the top of the viewport.

Nobody had filed a bug. Nobody had noticed, really — a shadow that's technically early by a few dozen pixels doesn't scream at you. But once you see it, the header looks stuck when it isn't, for just long enough to feel off.

Here's the fix that had shipped, more or less verbatim from a well-known pattern:

JavaScript
1
2
3
4
5
6
7
8
9
const sentinel = document.createElement('div'); sentinel.style.cssText = 'position:absolute; top:-1px; height:1px; width:1px;'; header.parentElement.insertBefore(sentinel, header); const observer = new IntersectionObserver( ([entry]) => header.classList.toggle('is-stuck', !entry.isIntersecting), { threshold: 0, rootMargin: '-1px 0px 0px 0px' } ); observer.observe(sentinel);

This is the standard way to detect a stuck position: sticky element without a scroll listener, and it's genuinely clever: plant an invisible 1-pixel tripwire just above the header, watch it with an IntersectionObserver, and the moment the tripwire scrolls out from under the (negative) root margin, the header must have hit its sticky offset. No scroll event, no layout thrash, no polling.

It also has a number baked into it that has nothing to do with your actual layout: that -1px margin assumes the header sticks at top: 0. Our header stuck at top: 64px, under a fixed announcement bar. Someone had hardcoded rootMargin: '-65px 0px 0px 0px' to compensate — and the day the announcement bar's height became responsive (48px on mobile, 64px on desktop), the sentinel's math stopped matching reality on exactly one breakpoint. The shadow started firing early again, and this time only on phones.

Guess what breaks it further: what happens to this whole setup if the header is sticky inside a scrollable panel, not the page itself — a sidebar, a modal, a data table with a frozen header row? Take a second before you scroll on.

The IntersectionObserver's default root is the viewport. Inside a scroll container, you'd need to pass that container in as root, track it down through the DOM, and keep that reference valid across re-renders. The sentinel trick doesn't just need a magic number — it needs you to manually tell it which scrolling box it even lives in.

Every one of those numbers — the sticky offset, which ancestor is actually scrolling, whether the element is stuck right now — is information the rendering engine computes on its own, every frame, just to paint a position: sticky element in the right place. We weren't detecting a new fact with the sentinel trick. We were re-deriving, in JavaScript, with a hardcoded guess, something the layout engine already had exactly right.

CSS container queries can now read that internal state directly. The feature is called scroll-state container queries, and the piece that matters here is scroll-state(stuck: ...):

CSS
1
2
3
4
5
6
7
8
9
10
11
.header-wrap { position: sticky; top: 64px; container-type: scroll-state; } @container scroll-state(stuck: top) { .header-inner { box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); } }

Two things to notice, because both are easy to get backwards the first time:

  • container-type: scroll-state goes on the sticky element itself — the one with position: sticky. That's what turns it into a query container whose scroll/stuck state other elements can read.
  • The @container scroll-state(stuck: top) rule has to target a different element than the one carrying container-type — here, a nested .header-inner, not .header-wrap. An element can't query its own container.

No sentinel div. No observer to instantiate, tear down, or leak. No rootMargin number to recompute when the layout above it changes — top: 64px lives in exactly one place, the same top the sticky positioning itself already uses, so there's nothing left to get out of sync.

Runs right in your browser — poke at it and watch the concept react live.

container-type: scroll-state and scroll-state() queries shipped in Chrome and Edge 133, in early 2025. As of this writing, Firefox and Safari haven't implemented them — this is a single-engine feature, not a baseline one yet, and a title that said otherwise would be lying to you.

That doesn't mean you need a JavaScript fork in every file that uses it. @supports already knows how to ask:

CSS
1
2
3
4
5
6
@supports (container-type: scroll-state) { .header-wrap { container-type: scroll-state; } @container scroll-state(stuck: top) { .header-inner { box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); } } }

Browsers that support the feature get the CSS-only path. Everywhere else, @supports simply doesn't match, and you fall through to the sentinel-and-observer version as a real fallback — not a hack you're embarrassed by, just progressive enhancement doing its actual job. The win isn't "delete the IntersectionObserver code today." It's that the code path doing the real work, the one with no stale magic number in it, is the one that gets to run in a growing share of real sessions, and the JS version becomes what it should have been from the start: a fallback, not the only implementation.

Container queries don't stop at stuck. The same scroll-state() function can also tell you whether a scroll-snap child is currently snapped, and whether a scrollable container still has more content in a given direction — the kind of state that today usually means a scroll-position calculation on every scroll event, for a "can scroll further" arrow that's wrong half the time anyway.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

The bug was never really about a shadow appearing 40 pixels early. It was that we'd built a parallel, worse copy of information the browser was already tracking correctly, and every layout change was a new chance for our copy to drift from the original. scroll-state() isn't a trick — it's the actual state, exposed.

Next time you reach for an IntersectionObserver and a tripwire div, ask first: is this something the browser already knows? What's the last "detect X from scroll position" snippet you wrote that you suspect was really just this?


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.