
A Subtle Query for a Sticky State
We’ve spent years using Intersection Observers and scroll hacks to detect when elements become 'stuck,' but a new CSS container query is about to make that logic obsolete.
I spent an entire afternoon in 2018 trying to figure out why a scroll event listener was tanking the frame rate on a marketing site. All I wanted was a simple shadow to appear under a navigation bar once it hit the top of the viewport. It seemed like such a trivial UI requirement, yet the "standard" solution involved attaching an event listener to the window, querying getBoundingClientRect() on every frame, and toggling a class. It felt like using a sledgehammer to crack a nut, and a clumsy one at that.
Later, we graduated to IntersectionObserver. It was better—asynchronous, more performant, and designed for exactly this kind of visibility tracking. But even then, we were still using JavaScript to manage what is fundamentally a visual state. We had to create "sentinel" elements (tiny, invisible divs placed 1px above the target) just to trigger the observer. It was a hack. A clever hack, but a hack nonetheless.
The web platform is finally closing this gap. With the introduction of CSS State Queries, specifically scroll-state queries within the Container Queries specification, we are moving toward a world where "stuckness" is a first-class citizen of CSS.
The Problem with the "Stuck" State
When you set an element to position: sticky; top: 0;, the browser handles the physics of the element. It stays in the flow until it hits that 0px offset, then it stays put. However, the browser doesn't inherently tell the CSS "Hey, I'm stuck now."
From a design perspective, this is a nightmare. A sticky header usually needs to look different when it's floating over content versus when it’s sitting in its natural home. You might want to shrink the padding, change the background opacity, or add a border-bottom.
Until now, the CSS was blind to the element's actual behavior. You could write:
.header {
position: sticky;
top: 0;
transition: background 0.3s;
}
/* We want this, but it doesn't exist yet in stable browsers */
.header:stuck {
background: white;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}Because :stuck isn't a real pseudo-class, we’ve been forced into the JavaScript-to-CSS bridge.
Enter Scroll State Queries
The CSS working group has been hard at work expanding Container Queries. While we’ve mostly used them for size (replacing media queries for components), the spec allows for other types of queries. The most exciting one for UI developers is scroll-state.
The idea is simple: a container can report its state—whether it is scrolled, whether it is stuck, or even its scroll direction—to its children.
The Modern Syntax
To detect if an element is stuck, we first have to define the container. But here is the nuance: an element can be its own container for scroll state in certain implementations, or it can query the state of its parent.
Here is how the emerging syntax looks:
/* Define the container that we want to observe */
.container {
container-type: scroll-state;
}
/* The sticky element inside that container */
.sticky-header {
position: sticky;
top: 0;
}
/* Querying the 'stuck' state */
@container scroll-state(stuck: top) {
.sticky-header {
background: #ffffff;
padding-block: 0.5rem;
border-bottom: 1px solid #e2e8f0;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
}Wait, what’s happening here?
1. container-type: scroll-state: This tells the browser to keep track of the scroll-related metadata of this element.
2. @container scroll-state(stuck: top): This is the magic. It asks, "Is the element I'm looking at currently stuck to the top?"
3. If the condition is met, the styles inside the block are applied.
No JavaScript. No sentinels. No ResizeObserver loops. Just pure, declarative CSS.
Why This Matters for Performance
When you use JavaScript to detect a sticky state, you are introducing a delay. The browser performs a layout pass, calculates that the element is stuck, fires an intersection event, your JS runs, you toggle a class, and the browser has to perform *another* layout and paint pass to apply your "stuck" styles.
This is a recipe for "jank." If the user is scrolling quickly, you might see the header jump or change styles a few milliseconds after it actually hits the top.
By moving this logic into the CSS engine, the browser can resolve these styles during the same layout pass. It knows the element is going to be stuck before it even paints the frame. This results in transitions that are perfectly synchronized with the scroll position.
A Practical Example: The Changing Sidebar
Let’s look at a more complex scenario. Imagine a sidebar navigation that is part of a long-form article. When it sticks, we want to highlight the current section title and perhaps show a "Back to Top" button that is otherwise hidden.
<main class="article-layout">
<aside class="sidebar-container">
<nav class="sticky-nav">
<ul>
<li><a href="#intro">Introduction</a></li>
<li><a href="#setup">The Setup</a></li>
<li><a href="#logic">The Logic</a></li>
</ul>
<button class="back-to-top">↑ Back to Top</button>
</nav>
</aside>
<article class="content">
<!-- Lots of content here -->
</article>
</main>In our CSS, we can handle the visibility of that button based entirely on the stuck state of the navigation:
.sidebar-container {
container-type: scroll-state;
height: 100%;
}
.sticky-nav {
position: sticky;
top: 2rem;
padding: 1rem;
border-radius: 8px;
background: transparent;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.back-to-top {
opacity: 0;
transform: translateY(10px);
pointer-events: none;
transition: inherit;
}
/* When the nav becomes stuck, style it and show the button */
@container scroll-state(stuck: top) {
.sticky-nav {
background: #f8fafc;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
.back-to-top {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
}I love how clean this is. The back-to-top button doesn't need its own logic; its existence is tied to the state of its parent.
The "Circular Dependency" Trap
One thing that tripped me up when first experimenting with container queries—and it applies heavily to scroll-states—is the risk of layout loops.
Imagine this:
1. An element becomes stuck.
2. The @container query triggers.
3. The styles inside the query change the element's height to be *smaller*.
4. Because it's smaller, it might no longer meet the criteria for being stuck (depending on your layout).
5. The browser removes the styles.
6. The element gets bigger again.
7. It becomes stuck again.
The browser is smart enough to detect these "infinite loops," but it usually handles them by simply breaking the query or picking one state and staying there. To avoid this, never change the dimensions or position of an element in a way that would affect its stuck status within the query itself.
Stick to changing colors, opacities, transforms (which don't affect layout), or internal padding that doesn't push the element out of its sticky trigger zone.
Real-World Gotchas and Browser Support
Now for the reality check: browser support. As of late 2023 and early 2024, scroll-state is the "new kid on the block." It’s part of the CSS Cascading and Inheritance Level 6 spec. Chrome has started implementation behind flags, and Safari/Firefox are watching closely.
Does this mean you can't use it? Not necessarily. This is a prime candidate for Progressive Enhancement.
You can write your "stuck" styles using the container query, and then provide a small JS fallback for older browsers. The beauty is that once the browser support catches up, you can simply delete the JS, and the CSS will take over without you changing a single line of your styling logic.
A Robust Fallback Pattern
If you need this today, I recommend using a tiny bit of JS to set a data attribute, but write your CSS to be ready for the future.
// The "Old Way" fallback
const observer = new IntersectionObserver(
([e]) => e.target.toggleAttribute('data-is-stuck', e.intersectionRatio < 1),
{ threshold: [1] }
);
observer.observe(document.querySelector('.sticky-header'));And your CSS:
/* Fallback using data attribute */
.sticky-header[data-is-stuck="true"] {
background: white;
}
/* Future-proof using scroll-state */
@container scroll-state(stuck: top) {
.sticky-header {
background: white;
}
}By the way, notice the threshold: [1] in the observer. This is a common trick. By setting top: -1px on a sticky element and observing when it intersects with the top of the viewport at a ratio of 1, you can detect the exact moment it sticks. It’s clever, but I’ll be glad when we can stop doing it.
Beyond Just "Stuck"
The scroll-state query isn't a one-trick pony. The specification actually outlines several states:
* stuck: As we've discussed (top, bottom, left, right).
* snapped: For CSS Scroll Snapping. You can style an element differently when it is the currently "snapped" item in a carousel.
* overflowing: This is a big one. Have you ever wanted to show a "scroll for more" arrow only if a container actually has more content? @container scroll-state(overflowing: bottom) makes that possible.
Let's look at the overflowing case because it's a very common UI pattern.
.scroll-area {
container-type: scroll-state;
overflow-y: auto;
max-height: 300px;
}
.scroll-indicator {
display: none;
}
@container scroll-state(overflowing: bottom) {
.scroll-indicator {
display: block;
position: absolute;
bottom: 10px;
/* Styles for an arrow or 'More' hint */
}
}This replaces a significant amount of "measuring" logic in JavaScript where we used to compare scrollHeight and clientHeight.
The Shift in Mindset
The most significant hurdle with Container Queries (of any kind) isn't the syntax—it's the shift in how we think about components.
For years, we've thought about the *viewport*. "If the screen is small, do this." Then we moved to *components*. "If this component is in a narrow sidebar, do this." Now, we are moving to *behavioral state*. "If this component is behaving a certain way (scrolling, snapping, sticking), do this."
This is "Intrinsic Design" in its purest form. The component is aware of its own environment and its own physical state.
I think about the thousands of lines of code I've written to manage "sticky" classes over the last decade. All that logic, all those event listeners, all those potential memory leaks... they are all being compressed into a single line of CSS: stuck: top.
Writing Clean, Scannable Container Queries
As you start implementing this, keep your code organized. Container queries can quickly become a "spaghetti" of nested blocks if you aren't careful. I find it helpful to group my state-based styles at the bottom of the component's CSS rule.
/* 1. Base Styles */
.card { ... }
/* 2. Layout Styles */
.card-grid { ... }
/* 3. Container Size Queries */
@container (min-width: 400px) { ... }
/* 4. Container State Queries */
@container scroll-state(stuck: top) { ... }By keeping the state queries separate, you make it much easier for the next developer (which will likely be you in six months) to understand *why* an element is changing its appearance.
Wrapping Up
We aren't quite at 100% global support yet, but the trajectory is clear. The browser is taking back the responsibility of managing UI state, and that is a massive win for web performance and developer sanity.
The next time you’re asked to build a header that shrinks on scroll or a sidebar that highlights when stuck, don't immediately reach for window.addEventListener('scroll'). Check the status of scroll-state queries. Experiment with them in Chrome Canary or behind flags.
The future of the "stuck" state is subtle, declarative, and built right into the platform. It's about time we stopped using JavaScript duct tape for CSS problems.


