Animation Intermediate

Sticky & snapped element styling without JavaScript

Styling elements differently when they become stuck (sticky) or snapped used to require JavaScript scroll event listeners. scroll-state() container queries let CSS respond to scroll-related states directly.

Old 14 lines
1/* JS scroll listener approach */
2const header = document
3  .querySelector('.header');
4const offset = header
5  .offsetTop;
6
7window.addEventListener(
8  'scroll', () => {
9    header.classList
10      .toggle('stuck',
11      window.scrollY
12        >= offset);
13  }
14);
Modern
8 lines
1.header-wrap {
2  container-type: scroll-state;
3}
4
5@container scroll-state(
6  stuck: top
7) {
8  .header { box-shadow: 0 2px 8px #0002; }
9}
scroll inside to see the header change
Sticky Header stuck
Content row 1
Content row 2
Content row 3
Content row 4
Content row 5
Content row 6
Content row 7
Content row 8
Content row 9
Content row 10
Content row 11
Content row 12
Content row 13
Content row 14
@container scroll-state(stuck: top)

No scroll listeners

The browser tracks stuck and snapped states internally. No scroll event handlers, no requestAnimationFrame, no jank.

Multiple states

Query stuck (top, bottom, left, right), snapped (x, y, inline, block), and overflowing states — all with pure CSS.

Container query model

Uses the familiar @container syntax. If you know container queries, you already know how to use scroll-state queries.

Browser Support
50%
Chrome Edge
View on caniuse.com →
States
stuck, snapped
Plus overflowing
Old Approach
JS scroll events
addEventListener
Modern Approach
scroll-state()
Container query

How it works

Sticky headers that add a shadow when stuck, or carousels that highlight the active slide — these patterns always required JavaScript. You'd listen to scroll events, calculate positions, and toggle classes. This causes layout thrashing, jank, and race conditions.

scroll-state() container queries let CSS detect scroll-related states natively. Set container-type: scroll-state on the scrolling ancestor, then query it: @container scroll-state(stuck: top) matches when a child is stuck to the top. @container scroll-state(snapped: x) matches the currently snapped element. The browser handles all the tracking with zero JavaScript.

ESC