v149 · css · shipped
CSS scroll-state() Container Queries
Style elements based on their scroll container's live state — whether a sticky item is stuck, a snap target is currently snapped, or a scroll container has overflowed — without any JavaScript polling.
at a glance
| Shipped in | Chrome 149 (desktop + Android) |
|---|---|
| Status | Enabled by default |
| Flag | None |
| Standards position | Spec in CSS Conditional Rules Level 5 |
| Spec | CSS Conditional Rules Level 5 §scroll-state |
| ChromeStatus | chromestatus.com/feature/5072263730167808 |
why it exists
Before scroll-state(), reacting to sticky headers, snap targets, or scrollable overflow required JavaScript observers — IntersectionObserver for stickiness, MutationObserver hacks for snap, ResizeObserver for overflow. These all involve layout recalculation callbacks that race with painting. scroll-state() exposes three commonly-needed scroll signals as CSS custom container properties, letting the browser style them synchronously during its own paint pass with no script overhead.
shape of the feature
Step 1: establish a scroll-state container
Add container-type: scroll-state to the element you want to query. You can combine it with a container-name for targeting.
.sticky-header {
position: sticky;
top: 0;
container-type: scroll-state;
container-name: sticky-nav;
}
Step 2: query with @container scroll-state()
Use the @container scroll-state() at-rule inside the container or on its descendants.
Supported scroll-state features
| Feature | Values | Matches when… |
|---|---|---|
stuck |
none | top | right | bottom | left | block-start | block-end | inline-start | inline-end |
The container has position: sticky and is visually shifted to stay inside its sticky view rectangle on that edge. none matches when not stuck on any edge. |
snapped |
none | x | y | block | inline | both |
The container is a snap target that is (or would be) snapped to its scroll container on the given axis. none when not snapped. |
scrollable |
none | top | right | bottom | left | block-start | block-end | inline-start | inline-end | x | y | block | inline | both |
The container is a scroll container with clipped content in that direction that the user can scroll to. Does not match for overflow: hidden containers. |
Scrollable-value disagreement, both sides reported rather than resolved (fetched 2026-10-07): this page lists both as a value of the scrollable feature, while the CSS Conditional Rules Level 5 spec defines scrollable as none | top | right | bottom | left | block-start | inline-start | block-end | inline-end | x | y | block | inline (no both) and defines both only for the snapped feature. Neither side is adjudicated here.
Feature-list disagreement, both sides reported rather than resolved (fetched 2026-10-07): this page documents three scroll-state features (stuck, snapped, scrollable), while the CSS Conditional Rules Level 5 spec additionally defines a fourth feature scrolled (none | top | right | bottom | left | block-start | inline-start | block-end | inline-end | x | y | block | inline). Neither side is adjudicated here.
example: style a sticky header when stuck
Show a box-shadow only when the header is actually pinned to the top — no JavaScript.
.page-header {
position: sticky;
top: 0;
container-type: scroll-state;
background: white;
transition: box-shadow 0.2s;
}
@container scroll-state(stuck: top) {
.page-header {
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
}
example: highlight the currently snapped carousel item
.carousel-item {
container-type: scroll-state;
opacity: 0.6;
transition: opacity 0.2s;
}
@container scroll-state(snapped: x) {
.carousel-item {
opacity: 1;
}
}
example: show a "more content below" indicator
.article {
overflow-y: auto;
max-height: 400px;
container-type: scroll-state;
container-name: article-scroller;
}
.scroll-hint {
display: none;
}
@container article-scroller scroll-state(scrollable: bottom) {
.scroll-hint {
display: block; /* visible only when there's more to scroll down */
}
}
browser support
| Chrome | 149+ (enabled by default) |
|---|---|
| Edge | 149+ (tracking Chromium) |
| Firefox | No signal yet |
| Safari | No signal yet |
Milestone disagreement, both sides reported rather than resolved (fetched 2026-10-07): this page says the feature shipped in "Chrome 149 (desktop + Android)", while the ChromeStatus record 5072263730167808 reports milestone 133 (desktop, Android and WebView 133, status "Enabled by default", last updated 2025-08-25). Neither side is adjudicated here.