01 article

CSS Scroll-State Queries Make Sticky-Header JavaScript Optional

Sticky headers that react to the scroll used to mean a scroll listener or IntersectionObserver plus a class toggle. Scroll-state container queries, now live in Chrome, Edge, Samsung Internet, and Opera, let the stylesheet answer the question itself.

CSS Scroll-State Queries Make Sticky-Header JavaScript Optional

CSS Scroll-State Queries Make Sticky-Header JavaScript Optional

For the last few years, making a sticky header react to the scroll has meant the same small ritual. Attach a scroll listener, or set an IntersectionObserver on an invisible sentinel, toggle a class when the header pins to the top, and remember to clean it up on unload. It works, and it is also one more listener to write, one more edge to test, and one more chance to jank a frame on a low-end phone.

Now the stylesheet can answer that question itself. Scroll-state container queries are live in Chrome, Edge, Samsung Internet, and Opera, and they let a rule apply based on what the scroll container is doing rather than its width or height. Three states, one keyword each.

Three states, one keyword each

Every query takes a keyword and a direction. scrollable asks whether the container has overflowing content you could scroll toward in that direction, so it is true only when there is actually something to scroll to. snapped asks whether the container is snapped to a scroll-snap ancestor along a given axis. stuck asks whether an element with position: sticky is pinned to an edge of its scroll container.

You declare the tracking on the element with container-type: scroll-state, and the browser updates the match as the user scrolls. No requestAnimationFrame throttle, no getBoundingClientRect, no class name to keep in sync. The engine already knows the scroll offset; these queries just expose it to the cascade.

The sticky header, minus the listener

Here is the one everyone is waiting for. Give the header the container type, and restyle it only while it is stuck to the top:

.site-header {
  position: sticky;
  top: 0;
  container-type: scroll-state;
}

@container scroll-state(stuck: top) {
  .site-header {
    background: color-mix(in oklab, var(--surface) 82%, transparent);
    backdrop-filter: blur(8px);
    box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
  }
}

At the very top of the page the header is flat and shadow-free. The instant you scroll and it pins, the blur and shadow appear. Scroll back up and they come off. That is the entire feature, and the old pattern was a sentinel, an observer, and a timeout, because you could not trust the first scroll event to fire at the right moment.

I kept finding the JavaScript version felt slightly off, and I think it is because you were inferring a layout state from a scroll position inside a callback, which fires before the sticky element has actually settled. The query observes the settled state directly, so there is nothing to get a frame behind.

Snapped slides and scroll cues

The same mechanism is quieter but useful for carousels. If a row of cards is a scroll-snap container, the snapped query tells each card when it is the one parked at the stop, so you can bring the active card forward and dim the rest without an IntersectionObserver:

.row {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}

.card {
  container-type: scroll-state;
  scroll-snap-align: start;
  opacity: 0.6;
}

@container scroll-state(snapped: x) {
  .card {
    opacity: 1;
  }
}

scrollable works the other direction, as a gate. A "scroll to load more" hint should not render when the list already fits on the screen, and scrollable: bottom is the honest way to check that there is more below the fold before you paint the affordance.

Where it stands, and how to ship it

The one thing that keeps this from being a default is that it is still a Chromium story. Chrome, Edge, Samsung Internet, and Opera have shipped it, which is a big slice of the web, but Firefox and Safari have not. The rules are not broken in those engines, they are simply inert, so the page degrades to the safe no-shadow, full-opacity state rather than anything odd.

Treat it as progressive enhancement. Ship the always-on default in plain CSS, layer the scroll-state rules on top, and you do not even need an @supports gate, because an unsupported at-rule is ignored rather than rejected. If you want the stuck shadow to show in Safari too, keep a small observer, but scope it to the engines that lack the query so you are not paying for it twice.

The traps

A few sharp edges. A stuck query is meaningless unless the element is actually position: sticky, so the container type and the position have to agree. A snapped query returns false unless an ancestor has a real scroll-snap-type, so it never fires on a plain scrollable div. And scrollable answers a question about capacity, not position, so do not reach for it when what you really want is "the user has scrolled past the hero"; that is a different signal, and the scrolled variant in the same family is the one for it on the engines that expose it.

The viewport counts as a scroll container, so you can query the root scroller without naming a container, which is how the sticky-header example reads its top edge. Once you name a container you are querying that box specifically, and nested scroll containers each track their own state, so a card inside a modal inside a scrollable panel reports against the nearest one that actually scrolls.

When to keep the JavaScript

You still reach for a listener when the scroll state has to drive something CSS cannot, like firing a fetch, logging an impression, or pausing a video mid-page. You also keep it where you need Safari coverage today. For the pure presentation cases, the shadow, the snapped highlight, the fade of a scroll cue, the query is fewer lines and cannot drift out of sync with the layout, because it is derived from the layout instead of measured against it.

Comments