Scroll-State Queries for Stuck and Snapped Motion
Part of Container Query Motion Triggers in Modern View Transitions & Scroll APIs.
The problem
A sticky section header should gain a shadow and shrink its title slightly when it becomes stuck. A carousel should enlarge the slide that is currently snapped. A horizontally scrollable table should show a fade at its right edge only while there is more content to scroll to. All three are usually built with scroll listeners or IntersectionObserver sentinels that add classes — code that runs on the main thread during scrolling and lags a frame behind.
These are states of a scroll container, and CSS can now query them directly.
Root cause analysis: scroll state as a container condition
container-type: scroll-state makes an element a container whose scroll-related state can be queried. Which states are available depends on the element.
stuck— on aposition: stickyelement: whether it is currently stuck to an edge, with values such astop,bottom,left,right,block-start.snapped— on a scroll snap target: whether it is the current snap target on an axis, such asx,y,inlineorblock.scrollable— on a scroll container: whether it can be scrolled further in a direction, such asrightorinline-end.
Queries style descendants, not the container itself. As with size queries, rules inside @container scroll-state(...) apply to elements inside the container. To restyle a sticky header when it sticks, make the header the container and style an inner element — which in practice means wrapping the visible content.
State changes are discrete; transitions make them smooth. The query flips when the state changes, typically once per crossing. Transitions on the affected properties animate the change, so the header’s shadow fades in rather than appearing.
Evaluated with scrolling, not by script. The browser updates these states as part of its own scroll handling. There is no scroll event handler, no sentinel element and no class toggling on the main thread per scroll event. Support is currently limited to Chromium-based browsers, so this is an enhancement over a static default.
Step-by-step resolution
Production code pattern
<section class="list-section">
<header class="section-head">
<div class="section-head__inner"><h2>March</h2></div>
</header>
…
</section>
/* Sticky header that reacts when stuck. */
.section-head {
position: sticky;
top: 0;
container-type: scroll-state;
}
.section-head__inner {
background: var(--color-surface);
box-shadow: 0 0 0 transparent;
transition: box-shadow var(--duration-200) ease-out;
}
.section-head__inner h2 {
transition: scale var(--duration-200) ease-out;
transform-origin: left center;
}
@container scroll-state(stuck: top) {
.section-head__inner { box-shadow: 0 6px 12px -8px rgb(0 0 0 / 0.35); }
.section-head__inner h2 { scale: 0.92; }
}
/* Carousel: the snapped slide's content scales up. */
.slide {
scroll-snap-align: center;
container-type: scroll-state;
}
.slide__card { scale: 0.94; opacity: 0.75; transition: scale var(--duration-300) var(--ease-standard), opacity var(--duration-300) linear; }
@container scroll-state(snapped: inline) {
.slide__card { scale: 1; opacity: 1; }
}
/* Scrollable table: fade hint only while there is more to the right. */
.table-scroll {
overflow-x: auto;
container-type: scroll-state;
}
.table-scroll__hint { opacity: 0; transition: opacity var(--duration-200) linear; }
@container scroll-state(scrollable: inline-end) {
.table-scroll__hint { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.section-head__inner, .section-head__inner h2,
.slide__card, .table-scroll__hint { transition: none; }
@container scroll-state(snapped: inline) { .slide__card { scale: 1; } }
.slide__card { scale: 1; }
}
Rendering Impact: composite for
scaleandopacity, paint for the header’sbox-shadowon a single small element. The state itself is evaluated by the browser’s scroll machinery; there are no per-scroll-event script callbacks.
The .table-scroll__hint element must sit inside the scroll container to be styled by the query, and positioned so it stays at the visible edge — typically position: sticky with inset-inline-end: 0 inside the scroller.
Combining with scroll-driven animations
Scroll-state queries are discrete: stuck or not, snapped or not. For continuous effects — a header that shrinks progressively over the first 80px of scroll — use a scroll-driven animation with a range instead. The two combine well: a scroll-driven animation handles the progressive shrink, and a scroll-state query adds a crisp state change, such as a divider line, at the moment the header sticks. The carousel indicators in scroll-snap carousel indicators with scroll timelines are another natural pairing: timelines for the progress bar, snapped for the active slide.
Verification checklist
Constraints and trade-offs
- Support is limited to Chromium-based browsers at the time of writing.
- Styling requires an inner wrapper because containers cannot query themselves.
stuckreflects the sticky constraint, not visual overlap; headers that stick at the very start of a scroller may report stuck immediately.- Scaling text in a sticky header can cause subpixel blur on some screens.
- Discrete states cannot express progress; pair with scroll-driven animations for continuous effects.
Frequently asked questions
What is container-type: scroll-state?
It makes an element a container whose scroll-related state — stuck, snapped or scrollable — can be queried with @container scroll-state() by its descendants.
Can a sticky element style itself when stuck?
Not directly; queries apply to descendants. Wrap the sticky element’s content and style the wrapper.
Do scroll-state queries replace scroll-driven animations?
No. Scroll-state queries are discrete states; scroll-driven animations map scroll progress continuously. They complement each other.
What happens in browsers without support?
The @container scroll-state() rules are ignored, so the default styles apply. Make those defaults acceptable, or keep an IntersectionObserver fallback.
Related
- Container Query Motion Triggers — the parent topic
- Tuning animation-range for Sticky Headers — continuous header effects
- Promoting Fixed and Sticky Elements — the layer cost of sticky content