CSS @container scroll-state: Replace JS scroll listeners now
Scroll containers have always been a weak spot in CSS. For years, if you wanted a navigation bar to restyle itself once it became sticky, or a photo to react when it entered the viewport, JavaScript was the only real option.
Most of us have written the same pattern: add a scroll listener, call getBoundingClientRect() a few times, flip some classes, and hope the main thread keeps up. It’s a lot of machinery for something that should be simple. And when it slips, you get jank – those subtle but frustrating moments where the UI lags behind your scroll.
That pattern is starting to fade. With the new @container scroll-state feature, CSS can respond directly to an element’s position within its scroll container. No polling. No manual measurements. This isn’t just another styling trick – it changes how we build scroll-driven interfaces. By letting the browser handle state detection inside its rendering pipeline, we get smoother motion, steadier frame rates, and far less code.
In this guide, we’ll replace heavy scroll listeners with declarative state queries and let CSS do the work.
Why the old approach doesn’t work
Before jumping into the new approach, it helps to see why the old one deserves to be retired. For more than a decade, scroll-driven effects have relied on window.addEventListener('scroll', ...) paired with getBoundingClientRect(). On the surface, it looks simple enough. In practice, though, it introduces performance problems that are surprisingly difficult to tame.
The scroll listener problem
As a user scrolls, the browser can fire dozens of scroll events every second. Each time your handler runs, the browser has to interrupt what it’s doing, execute your JavaScript, and then determine whether the screen needs to be repainted. That all happens inside a tight frame budget. At 60 frames per second, you have about 16.7 milliseconds to finish everything. Go over that, and the result is a visible stutter.
The issue with layout thrashing
The bigger problem is Layout Thrashing. When you call getBoundingClientRect(), you’re asking the browser to compute the precise size and position of an element at that exact moment. To provide an accurate answer, the browser might have to stop its smooth rendering process and perform a forced synchronous layout.
If you then change a style based on that measurement, like altering a background color or height, you create a situation where a write happens right after a read. If this occurs during a scroll event, the main thread spends more time recalculating layouts than actually rendering anything. This leads to dropped frames, less battery life, and a UI that feels slow and heavy.
How CSS state queries remove imperative scroll logic
The issue isn’t that the code is wrong – it’s that we’ve been solving a styling problem with the wrong tool. JavaScript is imperative by nature, and we’ve been using it to manage visual states that belong in a declarative layer. CSS state queries flip that model. They let the browser’s rendering engine handle detection internally. Instead of checking an element’s position over and over, we declare a condition: when this element is stuck, apply these styles. The browser handles the timing, batching, and optimization, leaving the main thread free for actual application logic.
Making that shift requires a small change in mindset. Rather than thinking in terms of scroll handlers, we think in terms of container state. Traditional container queries react to size. Scroll-state queries react to how an element relates to its scrollport.
Let’s look at how to define a scroll-state container and use the syntax in practice.
How @container scroll-state works
This involves two steps: defining the container that holds the state and writing a query for its child elements to respond to that state.
Turning an element into a queryable container
First, you tell the browser that an element’s scroll behavior should be observable. Whether it becomes stuck, snapped, or scrollable, that state needs to be tracked. You enable this by setting the container-type property to the new scroll-state value.
/* The element whose state we want to track */
.header {
position: sticky;
top: 0;
container-type: scroll-state;
container-name: sticky-nav; /* Optional: helps target specific containers */
}
Important rule: Like size-based container queries, an element cannot style itself based on its own scroll-state. The @container rule must target a descendant (a child or pseudo-element) of the container.
The query syntax
After defining the container, you can use the @container at-rule with the scroll-state() function. The syntax uses a straightforward condition-based logic:
@container <optional-name> scroll-state(<condition>) {
/* Styles applied when the condition is true */
}
The three core states
The scroll-state() function currently supports three main queries that replace common JavaScript scroll hacks:
1. Stuck state (stuck)
This query detects when a position: sticky element has attached itself to one of its boundaries. You can target positions like top, bottom, left, right, as well as logical values such as inset-block-start, inset-inline-end, or even none.
Use case: Add a shadow, tighten spacing, or shrink a logo once a header locks to the top of the viewport.
@container scroll-state(stuck: top) {
.nav-inner {
background: white;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
}
}
2. Snap state (snapped)
This query activates when an element aligns with a scroll-snap container. You can target alignment along the block, inline, x, or y axis.
Use case: Emphasize the active slide in a carousel by scaling it up, or reveal a caption when a photo snaps into the center.
@container scroll-state(snapped: inline) {
.card-content {
opacity: 1;
transform: scale(1.1);
}
}
3. Scrollable state (scrollable)
This query determines whether a container has overflow that can be scrolled in a given direction. You can check for values like top, bottom, left, right, and other logical directions.
Use case: Display a “Scroll for more” hint when additional content is available, and automatically hide it once the user reaches the end.
@container scroll-state(scrollable: bottom) {
.scroll-indicator {
display: block;
}
}
Browser support and progressive enhancement
As of early 2025, scroll-state queries are a new feature available in Chrome 133 and later. Since this is a nice-to-have visual enhancement, it suits progressive enhancement well.
You should place your state-specific logic inside an @supports block to ensure that users on older browsers still have a functional (though static) experience:
/* Fallback: Static styles for older browsers */
.nav-inner {
background: transparent;
}
/* Enhancement: Dynamic state-based styles */
@supports (container-type: scroll-state) {
@container scroll-state(stuck: top) {
.nav-inner {
background: white;
}
}
}
Core use cases
Sticky state (headers and navbars)
This demo shows a header that changes its style when it becomes sticky:
- It expands and then compresses
- The calm gradient switches to a sharp contrast
- The descriptive subtitle fades away
Normally, these changes require complex calculations and JavaScript.
What the user sees:
- At the top, there’s a large, open hero-style header
- Once it sticks, it transforms into a compact, focused navigation bar
No JavaScript or observers are needed.
See the Pen
@container sticky state by Miracle Jude (@JudeIV)
on CodePen.
Snap state (carousels and galleries)
Carousel with a pulse. This carousel highlights the active slide as it snaps into place. The focused card scales up, reveals its details, and the surrounding cards dim automatically. No JavaScript index tracking, and no scroll handlers needed.
See the Pen
@container snap state by Miracle Jude (@JudeIV)
on CodePen.
Edge detection (scroll indicators)
Smart scroll hint that disappears when not needed. This demo includes a scroll hint with a gradient and an arrow that:
- Shows up only when there is more content to scroll
- Automatically disappears at the end
- Adjusts to the size of the content
This functionality is usually managed with JavaScript and scroll height comparisons.
See the Pen
@container scrollable state by Miracle Jude (@JudeIV)
on CodePen.
Conclusion
@container scroll-state marks a meaningful shift in how CSS handles layout and interaction. Instead of wiring up observers or measuring positions in JavaScript, we let the browser decide when a scroll-related condition is true and apply styles accordingly. The detection happens inside the rendering pipeline, where it belongs.
The result is simpler code and smoother interfaces. There’s no need for IntersectionObserver, no manual bounding box checks, and no read-write layout cycles to manage. We describe the state we care about, and the browser handles the rest. That leads to UI that feels steady under scroll, with fewer moving parts and far less room for performance regressions.
The post CSS <code>@container</code> scroll-state: Replace JS scroll listeners now appeared first on LogRocket Blog.
This post first appeared on Read More


