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 a position: sticky element: whether it is currently stuck to an edge, with values such as top, bottom, left, right, block-start.
  • snapped — on a scroll snap target: whether it is the current snap target on an axis, such as x, y, inline or block.
  • scrollable — on a scroll container: whether it can be scrolled further in a direction, such as right or inline-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.

Scroll-state conditions and typical motionEach condition is queried on a different kind of element.Scroll-state conditions and typical motionContainer must beExample queryTypical motionstuckposition: stickyscroll-state(stuck: top)Shadow in, title shrinkssnappedA snap targetscroll-state(snapped: inline)Current slide scales upscrollableA scroll containerscroll-state(scrollable:inline-end)Edge fade appears
Each condition is queried on a different kind of element.

Step-by-step resolution

Replacing scroll listeners with scroll-state queriesContainer on the stateful element, styles on its children.Replacing scroll listeners with scroll-state queries1Wrap the sticky header's visible content in an inner element.Something to style inside the container2Set container-type: scroll-state on the sticky header.stuck is queryable3Write @container scroll-state(stuck: top) rules for the inner element.Styles apply when stuck4Add transitions to the inner element's shadow and scale.State changes animate5Repeat for snap targets and scroll containers as needed.snapped and scrollable effects6Remove the scroll listeners and sentinels once the fallback is acceptable.
Container on the stateful element, styles on its children.

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 scale and opacity, paint for the header’s box-shadow on 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.

Sentinel observer against scroll-state query for a sticky headerSame visual result; different machinery.Sentinel observer against scroll-state query for a sticky headerIntersectionObserver sentinelInvisible element above the headerCallback toggles a classCan lag a frame during fast scrollsWorks everywhere, more codecontainer-type: scroll-stateNo extra elements or scriptBrowser updates stuck stateChromium-only todayDeclarative enhancement
Same visual result; different machinery.

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.
  • stuck reflects 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.