![]() |
VOOZH | about |
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.
👁 ImageMost 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.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
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.
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 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.
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.
@container scroll-state worksThis involves two steps: defining the container that holds the state and writing a query for its child elements to respond to that state.
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.
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 scroll-state() function currently supports three main queries that replace common JavaScript scroll hacks:
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);
}
}
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);
}
}
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;
}
}
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;
}
}
}
This demo shows a header that changes its style when it becomes sticky:
Normally, these changes require complex calculations and JavaScript.
What the user sees:
No JavaScript or observers are needed.
See the Pen
@container sticky state by Miracle Jude (@JudeIV)
on CodePen.
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.
Smart scroll hint that disappears when not needed. This demo includes a scroll hint with a gradient and an arrow that:
This functionality is usually managed with JavaScript and scroll height comparisons.
See the Pen
@container scrollable state by Miracle Jude (@JudeIV)
on CodePen.
@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.
As web frontends get increasingly complex, resource-greedy features demand more and more from the browser. If you’re interested in monitoring and tracking client-side CPU usage, memory usage, and more for all of your users in production, try LogRocket.
👁 LogRocket Dashboard Free Trial BannerLogRocket lets you replay user sessions, eliminating guesswork around why bugs happen by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings — compatible with all frameworks.
LogRocket's Galileo AI watches sessions for you, instantly identifying and explaining user struggles with automated monitoring of your entire product experience.
Modernize how you debug web and mobile apps — start monitoring for free.
Learn how to build a full React Native auth system using Better Auth and Expo — with email/password login, Google OAuth, session persistence, and protected routes.
Compare the top AI development tools and models of June 2026. View updated rankings, feature breakdowns, and find the best fit for you.
Learn how Bloom filters reduce database lookups for username availability checks while preserving correctness at scale.
Learn how to test Nuxt apps with Vitest, @nuxt/test-utils, runtime mocks, server route mocks, and Playwright e2e tests.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now