Skip to main content
MEWA STUDIO

Scroll-State Queries, the Interface That Finally Knows It Is Sticky

Published on October 30th, 2026|12 min read
CSSdevelopmentUX

A 2013 proposal asked CSS for a pseudo-class to style a sticky element once it is stuck. An objection blocked it the same day. Chrome shipped the answer in 2025, in a form that still bears the mark of that objection.

Beams of glowing blue blades converging in perspective from the two upper edges of the image toward a black center

On August 27, 2013, Ojan Vafai, a Chromium developer, proposed on www-style, the mailing list of the W3C CSS Working Group, to add a pseudo-class (opens in a new tab) for position: sticky elements that are stuck to an edge. A header stuck to the top of the screen could then get a shadow or a background color with a single CSS rule. Less than an hour later, Tab Atkins, one of the main editors of the CSS specifications, replied with an objection (opens in a new tab). The pseudo-class would create a circularity. That objection blocked the idea for more than eleven years.

The answer arrived in Chrome 133 (opens in a new tab) in February 2025, in a different form. Scroll-state queries let CSS read four states that the browser computes while scrolling. Is an element stuck? Is it snapped to a stop point? Can its content still scroll? In which direction did the page scroll last? Twenty months after their release, they appear on 0.005% of pages loaded in Chrome (opens in a new tab). That is twenty-three times less than cross-document View Transitions (opens in a new tab), shipped eight months earlier.

Their syntax bears the mark of the 2013 objection. CSS still cannot style the stuck element itself, only what it contains. The constraint is surprising at first. It becomes logical once the original problem is understood.

Why CSS Could Not Know That an Element Is Stuck

A selector decides which styles apply to an element. Styles decide the layout. A selector that depended on layout could therefore change the very condition that makes it match. The shortest example fits in one rule.

css
.site-header {
  position: sticky;
  top: 0;
}

/* Hypothetical pseudo-class, never added to CSS */
.site-header:stuck {
  position: static;
}

The loop a :stuck pseudo-class would have created

The header gets stuck, the rule applies and removes position: sticky. Once the header is no longer stuck, the rule stops applying. It becomes sticky again and gets stuck once more. The browser has no stable state to display.

Forbidding position inside a :stuck rule is not enough, as Tab Atkins showed back in 2013. Another property set under :stuck can trigger a second mechanism that changes position in turn. Every property able to produce that effect would then have to be banned one by one, to the point of making the pseudo-class unusable.

The request came back in 2017 on the Working Group's GitHub repository. The issue asking for this pseudo-class (opens in a new tab) was closed on the day it was opened, July 27, 2017. Florian Rivoal, a member of the group, summed up the rule there. A selector cannot depend on layout, otherwise it could change the layout until it no longer matched before matching again, in "an infinite loop of contradictions". The answer has since been part of the Working Group's FAQ (opens in a new tab), which lists :stuck with :overflowing and :on-screen among the problems unlikely to be solvable in CSS.

What JavaScript Did Instead

Without a pseudo-class, the stuck state had to be detected in JavaScript. Eric Bidelman described the reference method in 2017 on the Chrome developers blog, starting from one observation. CSS provides no event to know when an element becomes sticky or stops being sticky (opens in a new tab). The technique places an invisible element, called a sentinel, just before the header. IntersectionObserver watches that sentinel. It is the JavaScript interface that reports when an element enters or leaves the visible area.

javascript
const header = document.querySelector(".site-header");
const sentinel = document.createElement("div");
sentinel.setAttribute("aria-hidden", "true");
header.before(sentinel);

const observer = new IntersectionObserver(([entry]) => {
  const isStuck = entry.boundingClientRect.top < 0;
  header.classList.toggle("is-stuck", isStuck);
});

observer.observe(sentinel);

Detecting the stuck state with a sentinel

When the sentinel leaves through the top of the screen, the header that follows it is stuck. The principle works. It requires adding one element to the document for each sticky element. The sentinel has to be offset as soon as the header sticks 20 pixels from the edge instead of zero. A class set in JavaScript must also stay in sync with styles written in CSS. The state only exists once the script has run.

The older method, still common, listens to the scroll event and measures the element's position on every move. That code runs on the main thread, the one that also handles clicks and rendering. According to the MDN documentation, even one of these listeners can cause performance problems (opens in a new tab). Measuring element positions in a loop while scrolling matches the scenario described in the article on rendering cost (opens in a new tab).

The same pattern applied to the other three states. Knowing which card of a carousel is centered, whether a wide table still hides columns on the right or whether the visitor is scrolling back up required a listener and a class every time.

The Detour Through Container Queries

Container queries opened another path. Available in every browser since February 2023 (opens in a new tab), they apply styles based on the size of a container rather than the size of the screen. On February 22, 2021, in an issue (opens in a new tab) where Adam Argyle revived the idea of layout-dependent selectors, Oriol Brufau suggested using that mechanism instead of a pseudo-class.

Miriam Suzanne, editor of the container queries specification, opened the issue defining queries on a container's state (opens in a new tab) in June 2021. The group deferred the question in October 2021. It decided on November 15, 2023 to add scroll-state() to the specification. The feature now lives in CSS Conditional Rules Level 5 (opens in a new tab). In 2025, Miriam Suzanne explained the choice to a developer who missed the pseudo-class. In her words, container queries provide one level of separation, which means that changes within the query are not able to change the result of the query (opens in a new tab).

In practice, the element that carries the state becomes a container. The query reads that container and can only apply styles to its descendants. The container itself stays out of reach. No rule triggered by the query can therefore remove its position: sticky.

The specification adds a second safeguard, a temporal one. It acknowledges that scroll state may cause layout cycles (opens in a new tab) and freezes the state of each container at specific points of the browser's rendering loop. All style and layout calculations use that snapshot until the next one. A style change therefore cannot re-evaluate the query while the browser is computing the current display.

Both safeguards have a cost in the HTML. The stuck element has to wrap a child that carries the visible styles. A header written as a single block gains one level of nesting.

Browser Support in October 2026

BrowserStatusDetails
Chrome and EdgeShippedstuck, snapped and scrollable since version 133 in February 2025. scrolled since version 144 (opens in a new tab) in January 2026
SafariBeing implementedWebKit opened the work (opens in a new tab) in April 2026. Evaluation of scrollable has been merged since September (opens in a new tab). stuck, snapped and scrolled are still to be done
FirefoxNot startedThe implementation bug (opens in a new tab), opened in November 2024, is assigned to no one

Scroll-state query support by browser in October 2026

The work merged into the WebKit engine has not reached Safari yet. No released version supports scroll-state queries according to MDN's compatibility data (opens in a new tab). Mozilla and Apple were asked for their official position on September 29, 2023. Both requests are still unanswered, on the Mozilla side (opens in a new tab) as on the WebKit side (opens in a new tab).

Interop is the yearly agreement through which browser vendors commit to a shared list of features. Proposed for Interop 2026, scroll-state queries were not selected (opens in a new tab) in February 2026. A new proposal (opens in a new tab) was filed in September for the 2027 edition. The Baseline status remains "limited availability".

The specification itself is still moving. The scrolled condition was called direction until a decision in September 2025 (opens in a new tab). Since July 2026, Tab Atkins has proposed to split the value (opens in a new tab) scroll-state of container-type back into three separate keywords.

The Fallback in Browsers Without Scroll-State Queries

Firefox and Safari ignore container-type: scroll-state, a value they do not recognize. They cannot evaluate the scroll-state() condition either, which leaves the @container block inactive. Visitors only see the styles written outside the query.

That behavior sets a writing rule. The state displayed by default must be the one that remains acceptable without the feature. A header that hides while scrolling down is coded visible by default and hidden inside the query. Without support, it stays visible. A back-to-top button hidden by default and revealed by the query, on the other hand, would never show up in Firefox. That case calls for @supports, which checks that a declaration is supported before applying the rules it contains. It is the logic of progressive enhancement (opens in a new tab), applied here rule by rule.

A Sticky Header That Changes Appearance Once Stuck

css
.site-header {
  position: sticky;
  top: 0;
  container-type: scroll-state;
}

.header-bar {
  transition: background-color 200ms, box-shadow 200ms;
}

@container scroll-state(stuck: top) {
  .header-bar {
    background-color: var(--surface-raised);
    box-shadow: 0 2px 12px rgb(0 0 0 / 0.12);
  }
}

A shadow on the stuck header

.site-header carries the sticky position and becomes the container. .header-bar, its direct child, receives the styles. The query walks up to the nearest scroll-state container among the ancestors of .header-bar and applies the rules when it is stuck to the top edge. Without support, the header keeps its normal appearance. The fallback rule is respected without any need for @supports.

Two details from the MDN documentation (opens in a new tab) prevent mistakes. The stuck condition is only evaluated on a position: sticky container placed inside an area that scrolls. The stuck: none query is true even without position: sticky. It does not prove that the mechanism works. An element stuck in a corner can also match two edges at once, top and left for example. When several containers are nested, container-name targets the right one with a query such as @container sticky-header scroll-state(stuck: top).

A Back-to-Top Button That Appears Once Scrolling Starts

The scrollable condition tests whether a container can still scroll in a given direction. scrollable: top is true as soon as the visitor has left the top of the area, since they can scroll back up to it. Set on the html element, it queries the whole page. The Working Group specified in May 2025 (opens in a new tab) that the query then applies to the visible viewport, because the root element never has a scrollbar of its own.

css
@supports (container-type: scroll-state) {
  html {
    container-type: scroll-state;
  }

  .back-to-top {
    translate: 0 200%;
    transition: translate 250ms ease-out;
  }

  @container scroll-state(scrollable: top) {
    .back-to-top {
      translate: 0 0;
    }
  }
}

Hiding the button only where the query can reveal it

The button is hidden only in browsers able to bring it back. Elsewhere, it stays visible at all times, as on a site that does not use the feature. The same condition helps with wide tables on mobile. scrollable: inline-end is true as long as columns remain on the right, which makes it possible to show a shadow or an arrow on that edge. Visitors then know they can scroll the table.

A Card That Stands Out When It Snaps

Scroll snap, which makes scrolling settle on stop points defined in CSS, makes it possible to build a carousel without a JavaScript library. The snapped condition tells which element is the target of that snapping.

css
.carousel {
  display: flex;
  gap: 1rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}

.slide {
  flex: 0 0 80%;
  scroll-snap-align: center;
  container-type: scroll-state;
}

.slide-card {
  transition: opacity 200ms, scale 200ms;
}

@container not scroll-state(snapped: x) {
  .slide-card {
    opacity: 0.5;
    scale: 0.92;
  }
}

Dimming the cards that are not snapped

Each slide becomes a container and its inner card receives the styles. The cards are written at full opacity. The query inverted with not dims the ones that are not snapped. A browser without support can evaluate neither the condition nor its negation. It displays every card normally. Writing the query the other way around, with cards dimmed by default and highlighted inside the query, would leave the whole carousel greyed out in Firefox and Safari.

According to MDN (opens in a new tab), evaluation follows the scrollsnapchanging event. That event fires as soon as the browser picks the next snap target, during the gesture. The card is therefore highlighted even before the finger is lifted. The information remains visual, however. A screen reader does not perceive this change in opacity. The carousel still needs its usual accessibility attributes to announce the current slide.

A Header That Hides While Scrolling Down

The scrolled condition, shipped in Chrome 144 (opens in a new tab) on January 13, 2026, reports the direction of the most recent scroll. It makes it possible to code in CSS the header that disappears when the visitor scrolls down and comes back as soon as they scroll up, a behavior built until now with a scroll listener.

css
html {
  container-type: scroll-state;
}

.site-header {
  position: fixed;
  inset: 0 0 auto;
  transition: translate 250ms ease-out;
}

@container scroll-state(scrolled: block-end) {
  .site-header:not(:focus-within) {
    translate: 0 -100%;
  }
}

@media (prefers-reduced-motion: reduce) {
  .site-header {
    transition: none;
  }
}

Hiding the header when the visitor scrolls down

block-end refers to the end of the vertical reading axis, which is the bottom of the page in a language written horizontally. The header is visible by default and hidden inside the query. Without support, it simply stays on screen. The transition is removed for people who asked their system to reduce motion. The header then switches state without sliding.

Two behaviors deserve attention. The first comes from the specification, which defines no reset (opens in a new tab). After the first scroll, the state keeps the last direction. A visitor who stops in the middle of an article after scrolling down keeps a hidden header until they scroll back up. That is the expected behavior of this pattern. It does not suit a bar that carries an action meant to stay visible, such as a buy button.

The second concerns the keyboard. Success criterion 2.4.11 of WCAG 2.2, the W3C accessibility guidelines, requires at level AA that an element receiving keyboard focus is not entirely hidden (opens in a new tab) by content from the site. A visitor tabbing to a link in the hidden menu would not see it. The :not(:focus-within) selector keeps the header in place as soon as one of its links receives focus.

Scroll-State Queries and Scroll-Driven Animations

Both features react to scrolling and are easy to confuse. Scroll-driven animations (opens in a new tab) move an animation forward along a scroll-based timeline (opens in a new tab). The value changes continuously, like a progress bar that fills up as the reader goes. Scroll-state queries are true or false conditions that switch a set of styles. The specification actually declares each of them as "discrete".

The choice follows the nature of the effect. A value that must track the exact scroll position belongs to a scroll-driven animation. A change of appearance that depends on a state belongs to a scroll-state query, with an ordinary CSS transition to soften the switch. The two combine on the same component. A progress bar can fill up continuously inside a header that gets its shadow once stuck.

What Scroll-State Queries Change in a Project

Scroll-state queries replace a whole category of small scripts with conditions that the browser evaluates itself at the right moment of its rendering. Sentinels, scroll listeners and classes added in JavaScript disappear from the code. The cost comes down to one extra wrapper element, inherited from the circularity that blocked :stuck for more than eleven years.

Support still limits the feature to enhancing the interface. Chrome and Edge apply all four conditions, no released version of Safari evaluates a single one and Firefox has not started. Writing the default state as the one that remains correct without support makes it possible to use the feature right now. Chrome and Edge visitors get an interface that reacts to its own state. Everyone else gets the version they would have had anyway.