Introduction
Container queries revolutionized responsive design by letting elements respond to their own size rather than the viewport. But size is only one dimension of an element's state. What if you could also query whether an element is snapped, stuck, or at a scroll boundary? CSS Scroll State Container Queries extend the container query model to include scroll-related state conditions, enabling styles that respond to scroll snap alignment, sticky positioning state, and scroll container boundaries — all without JavaScript.
This means you can style a sticky header differently when it is actually stuck to the top, highlight a carousel item when it is snapped to center, or show/hide scroll indicators based on whether a container has reached its scroll boundary. These patterns previously required IntersectionObserver, scroll event listeners, or scroll-linked class toggling. Now they are pure CSS, running on the compositor thread with zero JavaScript overhead.
This guide covers every scroll state condition, demonstrates practical patterns for real-world components, and shows how to combine scroll state queries with container size queries for truly responsive, scroll-aware components.
Understanding Scroll State Queries: Core Concepts
Container Query Recap
Container queries let you style elements based on the size of a containing element:
.card-container {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 1fr 2fr;
}
}The scroll-state() Function
Scroll state queries use the scroll-state() function inside @container:
@container scroll-state(stuck: top) {
.header {
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}The scroll-state() function accepts these conditions:
| Condition | Meaning | Use Case |
|---|---|---|
stuck: top | Element is stuck to the top of its scroll container | Sticky header shadow |
stuck: bottom | Element is stuck to the bottom | Sticky footer |
stuck: left | Element is stuck to the left | Sticky sidebar (horizontal) |
stuck: right | Element is stuck to the right | Sticky sidebar (horizontal) |
snapped | Element is snapped via scroll snap | Carousel highlight |
snapped: x | Snapped on the horizontal axis | Horizontal carousel |
snapped: y | Snapped on the vertical axis | Vertical snap sections |
scrollable: top | Container can scroll upward | Scroll-up indicator |
scrollable: bottom | Container can scroll downward | Scroll-down indicator |
scrollable: left | Container can scroll left | Left scroll indicator |
scrollable: right | Container can scroll right | Right scroll indicator |
Enabling Scroll State Queries
To use scroll-state(), the container must be declared with container-type: scroll-state:
.sticky-wrapper {
container-type: scroll-state;
}You can combine scroll-state and size containers:
.responsive-scroll {
container-type: inline-size scroll-state;
}Architecture and Design Patterns
Pattern 1: Sticky Header with Stuck State
.header-wrapper {
position: sticky;
top: 0;
container-type: scroll-state;
z-index: 100;
}
.header {
padding: 1rem 2rem;
background: transparent;
transition: background 0.3s, box-shadow 0.3s;
}
@container scroll-state(stuck: top) {
.header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
}Pattern 2: Carousel Snap Indicator
.carousel-item {
scroll-snap-align: center;
container-type: scroll-state;
opacity: 0.6;
scale: 0.9;
transition: opacity 0.3s, scale 0.3s;
}
@container scroll-state(snapped) {
.carousel-item-inner {
opacity: 1;
scale: 1;
}
}Pattern 3: Scroll Boundary Indicators
.scroll-container {
container-type: scroll-state;
overflow-y: auto;
max-height: 400px;
}
.scroll-indicator-top,
.scroll-indicator-bottom {
opacity: 0;
transition: opacity 0.3s;
}
/* Show top indicator when can scroll up */
@container scroll-state(scrollable: top) {
.scroll-indicator-top {
opacity: 1;
}
}
/* Show bottom indicator when can scroll down */
@container scroll-state(scrollable: bottom) {
.scroll-indicator-bottom {
opacity: 1;
}
}Pattern 4: Sticky Sidebar Active Section
.sidebar-wrapper {
position: sticky;
top: 4rem;
container-type: scroll-state;
}
.sidebar-link {
color: #999;
transition: color 0.3s;
}
@container scroll-state(stuck: top) {
.sidebar-link.active {
color: var(--brand);
font-weight: 600;
}
}Step-by-Step Implementation
Step 1: Create a Sticky Header with Stuck State
<header class="header-wrapper">
<nav class="header">
<a href="/" class="logo">MyApp</a>
<ul class="nav-links">
<li><a href="/features">Features</a></li>
<li><a href="/pricing">Pricing</a></li>
<li><a href="/docs">Docs</a></li>
</ul>
</nav>
</header>.header-wrapper {
position: sticky;
top: 0;
container-type: scroll-state;
z-index: 100;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
background: transparent;
transition: background 0.3s, box-shadow 0.3s, padding 0.3s;
}
@container scroll-state(stuck: top) {
.header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding-block: 0.5rem;
}
}Step 2: Build a Snap Carousel with Active State
<div class="carousel">
<div class="carousel-item">
<div class="carousel-card">
<h3>Card 1</h3>
<p>Content for card 1</p>
</div>
</div>
<div class="carousel-item">
<div class="carousel-card">
<h3>Card 2</h3>
<p>Content for card 2</p>
</div>
</div>
<!-- More items -->
</div>.carousel {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scroll-padding: 1rem;
padding: 2rem 1rem;
}
.carousel-item {
flex: 0 0 85%;
scroll-snap-align: center;
container-type: scroll-state;
}
.carousel-card {
padding: 2rem;
background: var(--surface);
border-radius: 12px;
border: 2px solid transparent;
opacity: 0.6;
scale: 0.95;
transition: all 0.3s ease;
}
@container scroll-state(snapped) {
.carousel-card {
opacity: 1;
scale: 1;
border-color: var(--brand);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}
}Step 3: Add Scroll Boundary Fade Indicators
.message-list {
container-type: scroll-state;
overflow-y: auto;
max-height: 500px;
position: relative;
}
.fade-top,
.fade-bottom {
position: sticky;
height: 40px;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
.fade-top {
top: 0;
background: linear-gradient(to bottom, var(--surface), transparent);
}
.fade-bottom {
bottom: 0;
background: linear-gradient(to top, var(--surface), transparent);
}
@container scroll-state(scrollable: top) {
.fade-top { opacity: 1; }
}
@container scroll-state(scrollable: bottom) {
.fade-bottom { opacity: 1; }
}Step 4: Combine with Size Container Queries
.responsive-card {
container-type: inline-size scroll-state;
}
@container (min-width: 500px) {
.card-layout {
display: grid;
grid-template-columns: 200px 1fr;
}
}
@container scroll-state(snapped) {
.card-highlight {
border: 2px solid var(--brand);
}
}Step 5: Sticky Footer with Stuck State
.footer-wrapper {
position: sticky;
bottom: 0;
container-type: scroll-state;
}
.footer-bar {
padding: 0.75rem 1rem;
background: transparent;
transform: translateY(100%);
transition: transform 0.3s, background 0.3s;
}
@container scroll-state(stuck: bottom) {
.footer-bar {
background: var(--surface);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(0);
}
}Step 6: Progressive Enhancement
/* Fallback: always show indicator */
.scroll-indicator {
opacity: 1;
}
/* Enhanced: only show when scrollable */
@container scroll-state(scrollable: bottom) {
.scroll-indicator {
opacity: 1;
}
}
/* Fallback header: always styled */
.header {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* Enhanced: transparent when not stuck */
@supports (container-type: scroll-state) {
.header {
background: transparent;
box-shadow: none;
}
}Real-World Use Cases
Use Case 1: Chat Application with Scroll Indicators
.chat-messages {
container-type: scroll-state;
overflow-y: auto;
flex: 1;
}
.new-messages-badge {
position: absolute;
bottom: 1rem;
right: 1rem;
background: var(--brand);
color: white;
padding: 0.5rem 1rem;
border-radius: 20px;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s;
}
@container scroll-state(scrollable: bottom) {
.new-messages-badge {
opacity: 1;
transform: translateY(0);
}
}Use Case 2: Navigation with Active Section Highlighting
.page-section {
scroll-snap-align: start;
container-type: scroll-state;
}
.nav-link {
color: #999;
position: relative;
}
.nav-link::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
width: 100%;
height: 2px;
background: var(--brand);
transform: scaleX(0);
transition: transform 0.3s;
}
@container scroll-state(snapped: y) {
.nav-link::after {
transform: scaleX(1);
}
.nav-link {
color: var(--brand);
font-weight: 600;
}
}Use Case 3: Sticky Table Header
.table-wrapper {
overflow: auto;
max-height: 600px;
}
.thead-wrapper {
position: sticky;
top: 0;
container-type: scroll-state;
}
.thead {
background: var(--surface);
transition: box-shadow 0.3s;
}
@container scroll-state(stuck: top) {
.thead {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
}
}Use Case 4: Image Gallery with Snap Feedback
.gallery {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 1rem;
padding: 1rem;
}
.gallery-item {
flex: 0 0 300px;
scroll-snap-align: center;
container-type: scroll-state;
border-radius: 12px;
overflow: hidden;
}
.gallery-item img {
width: 100%;
aspect-ratio: 4/3;
object-fit: cover;
filter: grayscale(50%);
transition: filter 0.4s;
}
@container scroll-state(snapped) {
.gallery-item img {
filter: grayscale(0%);
}
}Best Practices for Production
-
Always declare
container-type: scroll-state: Thescroll-state()function only works on containers that explicitly opt in with this declaration. Without it, the query silently fails. -
Combine with size containers when needed: Use
container-type: inline-size scroll-stateto query both size and scroll state from the same container. -
Use transitions for smooth state changes: The stuck and snapped states toggle instantly, but CSS transitions make the visual change smooth:
.header {
transition: background 0.3s, box-shadow 0.3s;
}- Provide fallbacks for unsupported browsers: Not all browsers support scroll state queries yet. Use
@supportsto provide static fallbacks:
@supports not (container-type: scroll-state) {
.header { background: white; }
}-
Use
scrollableindicators sparingly: Showing scroll indicators for every scrollable container can be noisy. Reserve them for important containers like chat windows and long lists. -
Test with real scroll interactions: Scroll state is determined by the browser based on actual scroll position. Test by scrolling manually, not by setting scroll position programmatically.
-
Combine with scroll-driven animations: Use
scroll-state()for discrete state changes (stuck/not-stuck) and scroll-driven animations for continuous progress tracking. -
Name your containers for complex layouts: When multiple containers exist in the same DOM hierarchy, use
container-nameto disambiguate:
.header-wrapper {
container-name: header;
container-type: scroll-state;
}
@container header scroll-state(stuck: top) {
.header { background: white; }
}Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
Missing container-type: scroll-state | Query silently fails | Always declare the container type |
Using scroll-state() without @container | Invalid syntax | Wrap in @container scroll-state(...) |
Querying non-sticky element for stuck | Never matches | Ensure the element has position: sticky |
Expecting smooth transitions without transition | Instant state change | Add transition to the affected properties |
Combining scroll-state with size incorrectly | Container type mismatch | Use container-type: inline-size scroll-state |
| Testing with JS scroll instead of real user scroll | State may not update | Test with real scroll gestures |
Performance Optimization
Scroll state queries are evaluated by the browser's compositor, meaning they run off the main thread. There is no JavaScript overhead, no event listener cost, and no layout recalculation triggered by state changes. The browser natively tracks stuck, snapped, and scrollable states as part of its rendering pipeline.
For optimal performance:
- Limit the number of scroll-state containers: Each container requires the browser to track its scroll state. Having hundreds of containers with
scroll-statequeries can increase compositor workload. - Use
container-namefor targeted queries: Named containers are more efficient because the browser can skip unrelated containers when evaluating a query. - Combine related state queries: Instead of separate containers for different states, query multiple conditions from the same container.
Comparison with Alternatives
| Feature | Scroll State Queries | IntersectionObserver | scroll Event JS | GSAP ScrollTrigger |
|---|---|---|---|---|
| JavaScript required | No | Yes | Yes | Yes |
| Stuck state detection | Native | Not possible | Manual calculation | Manual |
| Snap state detection | Native | Not possible | Manual | Manual |
| Scrollable boundary detection | Native | Partial | Manual | Manual |
| Compositor-thread | Yes | No | No | Partial |
| Bundle size | 0 KB | 0 KB (native) | Varies | ~30 KB |
| Browser support | Emerging (2025) | Universal | Universal | Universal |
Advanced Patterns
Combining Stuck and Size Queries
.header-wrapper {
container-type: inline-size scroll-state;
}
/* Small screen + stuck: compact header */
@container (max-width: 640px) and scroll-state(stuck: top) {
.header {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
}
/* Large screen + stuck: full header with shadow */
@container (min-width: 1024px) and scroll-state(stuck: top) {
.header {
padding: 0.75rem 3rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}Bidirectional Scroll Indicators
.table-scroll {
container-type: scroll-state;
overflow: auto;
}
.scroll-hint-left,
.scroll-hint-right {
position: absolute;
top: 0;
bottom: 0;
width: 40px;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
.scroll-hint-left {
left: 0;
background: linear-gradient(to right, var(--surface), transparent);
}
.scroll-hint-right {
right: 0;
background: linear-gradient(to left, var(--surface), transparent);
}
@container scroll-state(scrollable: left) {
.scroll-hint-left { opacity: 1; }
}
@container scroll-state(scrollable: right) {
.scroll-hint-right { opacity: 1; }
}Snap-Aware Progress Bar
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
container-type: scroll-state;
}
.progress-dots {
display: flex;
gap: 0.5rem;
justify-content: center;
padding: 1rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ddd;
transition: background 0.3s, scale 0.3s;
}
@container scroll-state(snapped: x) {
.dot.active {
background: var(--brand);
scale: 1.3;
}
}Testing Strategies
import { test, expect } from '@playwright/test';
test('sticky header gets stuck state when scrolled', async ({ page }) => {
await page.goto('/demo');
const header = page.locator('.header');
// Initially not stuck: no shadow
await expect(header).toHaveCSS('box-shadow', 'none');
// Scroll past the header
await page.evaluate(() => window.scrollTo(0, 300));
await page.waitForTimeout(200);
// Header should now be stuck with shadow
const shadow = await header.evaluate(el => getComputedStyle(el).boxShadow);
expect(shadow).not.toBe('none');
});
test('carousel item highlights when snapped', async ({ page }) => {
await page.goto('/demo');
const carousel = page.locator('.carousel');
const firstItem = page.locator('.carousel-item').first();
// Scroll carousel to snap second item
await carousel.evaluate(el => el.scrollLeft = 400);
await page.waitForTimeout(300);
// Second item should be highlighted
const secondCard = page.locator('.carousel-item:nth-child(2) .carousel-card');
const opacity = await secondCard.evaluate(el => getComputedStyle(el).opacity);
expect(opacity).toBe('1');
});Future Outlook
Scroll State Container Queries are part of the broader Container Queries specification expansion. The feature is shipping in Chrome 133+ and is being implemented in Firefox and Safari. Future additions may include:
scroll-state: overscroll: Detect when a user has scrolled past the boundary (pull-to-refresh).scroll-state: scrolling: Detect active scrolling vs. idle state.scroll-state: direction: Query the scroll direction (up/down/left/right).- Integration with View Transitions API: Automatic transitions triggered by scroll state changes.
The combination of size container queries, scroll state queries, and scroll-driven animations creates a complete toolkit for building scroll-aware components entirely in CSS.
Conclusion
Scroll State Container Queries extend the container query model to include scroll-related conditions. With scroll-state(stuck), scroll-state(snapped), and scroll-state(scrollable), you can build components that respond to scroll context without any JavaScript.
Key takeaways:
- Declare
container-type: scroll-stateon the wrapper element to enable scroll state queries. - Use
stuck: top/bottomfor sticky headers and footers to add visual feedback when they are stuck. - Use
snappedfor carousel items to highlight the active/snaped item. - Use
scrollable: top/bottom/left/rightto show scroll boundary indicators. - Combine with size container queries using
container-type: inline-size scroll-state. - Always provide
@supportsfallbacks for browsers that do not yet support the feature.
Start by adding stuck-state styling to your sticky header. The immediate visual feedback — a subtle shadow appearing when the header sticks — improves the perceived quality of your entire application with just a few lines of CSS.