Modern responsive interfaces often need to react to state, not merely viewport width. CSS scroll-state container queries let a component style itself when its scroll container is stuck, scrollable, or snapped—without attaching a scroll listener, calculating offsets, or pushing state through JavaScript. For sticky headers, carousel controls, and scrollytelling UI, this moves a fragile runtime concern into declarative CSS.
TL;DR
Use scroll-state queries when the browser already knows the answer to a visual question: “is this sticky item currently pinned?”, “can this region scroll?”, or “which item is snapped into place?” They reduce listener code and avoid timing bugs, but they are progressive enhancement: keep navigation and controls usable when the feature is unavailable. They complement, rather than replace, CSS scroll-driven animations and the Navigation API.
Table of contents
- What are CSS scroll-state queries?
- Why are scroll listeners a poor default?
- How do sticky-state queries work?
- How can a component expose scroll affordance?
- Where do snapped items help?
- What are the production guardrails?
- FAQ
What are CSS scroll-state queries?
Scroll-state queries are a family of CSS container queries. Instead of asking whether a container is 600 pixels wide, a rule asks about a scroll-related condition associated with an element. The current proposal covers three useful states:
stuck: aposition: stickyelement is currently held at one of its sticky edges.scrollable: a scroll container has overflow in a direction.snapped: an item participating in scroll snapping is currently selected by the snap position.
The important design detail is that the queried element opts in with container-type: scroll-state. Descendants then respond through @container scroll-state(...). This keeps the relationship local: a reusable component declares the state it offers, while its internal styles decide how to present it.
The CSS Conditional Rules specification defines the syntax, and MDN’s container-query reference explains the broader container-query model. Browser support changes quickly, so check a feature’s current baseline before making it fundamental to a flow.
Why are scroll listeners a poor default?
JavaScript is still right when scroll position changes application data, telemetry, or routing. But it is overkill when it only toggles a shadow, fades a label, or highlights a visible card. A typical listener has hidden costs:
- It must be registered on the correct scrolling element—not always
window. - It needs geometry reads such as
getBoundingClientRect(), which can become expensive when mixed with writes. - It needs resize handling because thresholds move as content and fonts load.
- It needs cleanup, keyboard and reduced-motion consideration, and tests for nested scrollers.
CSS does not remove the browser’s work; it removes our duplicate bookkeeping. The layout engine already decides whether sticky positioning is active and whether overflow exists. Letting CSS consume that decision avoids a second, often imperfect model of layout in JavaScript.
That is similar to the benefit of CSS sibling functions: represent a presentational relationship where it belongs instead of adding coordination code just to flip classes.
How do sticky-state queries work?
A common example is a section heading that becomes a compact “currently reading” marker only after it sticks. The heading is both the sticky item and the query container:
.section-title {
position: sticky;
top: 0;
container-type: scroll-state;
background: Canvas;
padding: 0.75rem 1rem;
transition: box-shadow 160ms ease, font-size 160ms ease;
}
@container scroll-state(stuck: top) {
.section-title__eyebrow {
display: inline;
}
.section-title {
box-shadow: 0 2px 12px rgb(0 0 0 / 12%);
}
}The exact descendant selector may vary with a component’s markup; the pattern is what matters. The base style works without the query. The query adds an affordance once the header is actually stuck—not when a guessed pixel threshold says it should be.
This is especially valuable for nested documentation panes, modal sidebars, and dashboards where the sticky boundary is not the page’s top edge. Use logical design where possible, test all sticky edges you support, and do not hide the only context or navigation inside the enhanced state.
For the platform rationale and examples, see Chrome’s scroll-state queries announcement. It is a good reminder that sticky can be a state signal as well as a positioning tool.
How can a component expose scroll affordance?
Horizontal tabs and code blocks frequently need an unobtrusive cue that more content is available. A scrollable query can make an inline gradient or edge control visible only when the region has overflow:
.tabs {
overflow-x: auto;
display: flex;
gap: 0.5rem;
container-type: scroll-state;
scrollbar-width: thin;
}
.tabs-wrap::after {
content: "";
pointer-events: none;
opacity: 0;
transition: opacity 160ms ease;
}
@container scroll-state(scrollable: inline-end) {
.tabs-wrap::after {
opacity: 1;
background: linear-gradient(to left, Canvas, transparent);
}
}Treat the gradient as a hint, not the interaction itself. Users must still be able to scroll with touch, wheel, keyboard, and assistive technology. If the design includes previous/next buttons, they should retain accessible names and functional fallback behavior; CSS state can decorate them but should not be their only source of truth.
This component-level approach pairs well with CSS if(), where a visual choice can likewise stay in CSS rather than be lifted into a UI framework state store.
Where do snapped items help?
scroll-snap-type is commonly used for product galleries, editorial rails, and onboarding pages. The missing piece has been styling the item that the browser considers snapped without a scroll observer. A snapped-state query lets the selected panel change its treatment:
.rail {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(18rem, 80%);
overflow-x: auto;
scroll-snap-type: inline mandatory;
}
.card {
scroll-snap-align: center;
container-type: scroll-state;
scale: 0.96;
opacity: 0.72;
}
@container scroll-state(snapped: inline) {
.card {
scale: 1;
opacity: 1;
}
}Keep the animation subtle and honor prefers-reduced-motion; snapping should aid navigation, not turn every swipe into a theatrical effect. Avoid using visual selection as proof that a purchase option or form step is “chosen.” That belongs in semantic controls and application state.
What are the production guardrails?
Adopt scroll-state queries as progressive enhancement. The base component should communicate overflow, preserve focus order, and make controls usable with no special query applied. Then layer the enhancement behind feature detection:
@supports (container-type: scroll-state) {
.section-title,
.tabs,
.card {
container-type: scroll-state;
}
}
@media (prefers-reduced-motion: reduce) {
.section-title,
.tabs-wrap::after,
.card {
transition: none;
}
}Before shipping, test with zoom, keyboard scrolling, forced colors, narrow and wide containers, and real content that changes height after load. In React or Next.js, resist adding a parallel isStuck state just for cosmetics; it can drift from the browser’s layout state during hydration and resize. The same discipline that makes declarative Shadow DOM useful—letting the platform describe structure directly—applies here.
Also separate visual state from product state. For analytics such as “the third card was viewed for two seconds,” use an IntersectionObserver or application event. For animation synchronized to scroll progress, use the purpose-built scroll-timeline APIs. For a visual flourish based on a known scroll condition, scroll-state queries are the smaller, more durable tool.
A practical adoption plan
- Find one listener that only toggles a CSS class from geometry.
- Confirm it maps to sticky, overflow, or snap state—not business logic.
- Build the component’s usable baseline first.
- Add
container-type: scroll-stateand one query as an enhancement. - Test nested scrollers, keyboard input, zoom, and reduced motion.
- Remove the listener only after checking the browsers your audience actually uses.
The win is not a smaller bundle at any cost. It is a clearer boundary: JavaScript owns data and behavior; CSS owns layout-derived presentation. When that boundary is true, scroll-state queries replace brittle code with a rule that says exactly what the UI means.
FAQ
Can scroll-state queries replace IntersectionObserver?
No. Use IntersectionObserver when JavaScript needs to know visibility for loading, analytics, or application behavior. Scroll-state queries are for descendant styling based on browser scroll conditions.
Are scroll-state queries the same as scroll-driven animations?
No. A scroll-state query answers a discrete condition such as “stuck at the top.” Scroll-driven animations map scroll progress to an animation timeline. They can coexist in the same interface.
Should I remove every scroll event listener?
No. Remove only listeners that duplicate a presentational sticky, overflow, or snap condition. Retain JavaScript where it changes data, manages focus, or records a meaningful interaction.
What is the safest first use case?
A sticky heading that gains a shadow when pinned, or a horizontally overflowing tab list that displays a visual overflow cue. Both have straightforward fallbacks.