MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

CSS Scroll State Container Queries

Query scroll state with CSS: snapped, stuck, and scroll-timeline conditions.

CSSScrollContainer QueriesFrontend

By MinhVo

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.

CSS Scroll State Container Queries illustration

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:

ConditionMeaningUse Case
stuck: topElement is stuck to the top of its scroll containerSticky header shadow
stuck: bottomElement is stuck to the bottomSticky footer
stuck: leftElement is stuck to the leftSticky sidebar (horizontal)
stuck: rightElement is stuck to the rightSticky sidebar (horizontal)
snappedElement is snapped via scroll snapCarousel highlight
snapped: xSnapped on the horizontal axisHorizontal carousel
snapped: ySnapped on the vertical axisVertical snap sections
scrollable: topContainer can scroll upwardScroll-up indicator
scrollable: bottomContainer can scroll downwardScroll-down indicator
scrollable: leftContainer can scroll leftLeft scroll indicator
scrollable: rightContainer can scroll rightRight 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;
}

Scroll state diagram

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);
  }
}
.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;
  }
}
<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);
  }
}
.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;
  }
}

Implementation workflow

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;
  }
}
.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

  1. Always declare container-type: scroll-state: The scroll-state() function only works on containers that explicitly opt in with this declaration. Without it, the query silently fails.

  2. Combine with size containers when needed: Use container-type: inline-size scroll-state to query both size and scroll state from the same container.

  3. 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;
}
  1. Provide fallbacks for unsupported browsers: Not all browsers support scroll state queries yet. Use @supports to provide static fallbacks:
@supports not (container-type: scroll-state) {
  .header { background: white; }
}
  1. Use scrollable indicators sparingly: Showing scroll indicators for every scrollable container can be noisy. Reserve them for important containers like chat windows and long lists.

  2. 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.

  3. Combine with scroll-driven animations: Use scroll-state() for discrete state changes (stuck/not-stuck) and scroll-driven animations for continuous progress tracking.

  4. Name your containers for complex layouts: When multiple containers exist in the same DOM hierarchy, use container-name to disambiguate:

.header-wrapper {
  container-name: header;
  container-type: scroll-state;
}
 
@container header scroll-state(stuck: top) {
  .header { background: white; }
}

Common Pitfalls and Solutions

PitfallImpactSolution
Missing container-type: scroll-stateQuery silently failsAlways declare the container type
Using scroll-state() without @containerInvalid syntaxWrap in @container scroll-state(...)
Querying non-sticky element for stuckNever matchesEnsure the element has position: sticky
Expecting smooth transitions without transitionInstant state changeAdd transition to the affected properties
Combining scroll-state with size incorrectlyContainer type mismatchUse container-type: inline-size scroll-state
Testing with JS scroll instead of real user scrollState may not updateTest 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-state queries can increase compositor workload.
  • Use container-name for 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

FeatureScroll State QueriesIntersectionObserverscroll Event JSGSAP ScrollTrigger
JavaScript requiredNoYesYesYes
Stuck state detectionNativeNot possibleManual calculationManual
Snap state detectionNativeNot possibleManualManual
Scrollable boundary detectionNativePartialManualManual
Compositor-threadYesNoNoPartial
Bundle size0 KB0 KB (native)Varies~30 KB
Browser supportEmerging (2025)UniversalUniversalUniversal

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:

  1. Declare container-type: scroll-state on the wrapper element to enable scroll state queries.
  2. Use stuck: top/bottom for sticky headers and footers to add visual feedback when they are stuck.
  3. Use snapped for carousel items to highlight the active/snaped item.
  4. Use scrollable: top/bottom/left/right to show scroll boundary indicators.
  5. Combine with size container queries using container-type: inline-size scroll-state.
  6. Always provide @supports fallbacks 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.