:has() Selector

Widely Supported

The :has() pseudo-class allows you to style parent elements based on their children, eliminating the need for JavaScript.

Demo 1: Form Validation with Parent Styling

As you type, the form group changes color based on input validity. No JavaScript required!

Please enter a valid email address
Password must be at least 8 characters
Please enter a valid phone number

Demo 2: Card Hover Effects

Hover over the image inside the card to see the entire card change appearance.

Product 1

Modern Headphones

Premium wireless audio experience

$199.99

Product 2

Smart Watch

Track your fitness goals

$299.99

Product 3

Laptop Stand

Ergonomic workspace solution

$79.99

Demo 3: Sibling Fade-Out Gallery

Hover over any photo to fade out the others. Pure CSS, no JavaScript!

Code Example: Form Validation

/* Style container when input is invalid and not empty */
.form-group:has(input:invalid:not(:placeholder-shown)) {
  border: 2px solid #ef4444;
  background-color: #fee2e2;
}

/* Style container when input is valid */
.form-group:has(input:valid:not(:placeholder-shown)) {
  border: 2px solid #10b981;
  background-color: #f0fdf4;
}

/* Style label based on input state */
.form-group:has(input:invalid) label {
  color: #ef4444;
  font-weight: 600;
}

/* Show error message only when invalid */
.form-group:has(input:invalid:not(:placeholder-shown)) .error-text {
  display: block;
}

Code Example: Card Hover

/* Highlight card when image is hovered */
.product-card:has(.card-image:hover) {
  box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
  transform: translateY(-8px);
}

/* Enlarge title on image hover */
.product-card:has(.card-image:hover) .card-title {
  font-size: 1.5rem;
  color: #3b82f6;
}

/* Show hidden button on image hover */
.product-card:has(.card-image:hover) .card-button {
  opacity: 1;
  pointer-events: auto;
}

Code Example: Sibling Fade-Out

/* Fade out all siblings when one is hovered */
.has-gallery-demo:has(.gallery-item:hover) .gallery-item:not(:hover) {
  opacity: 0.4;
  filter: grayscale(100%);
}

/* Scale up the hovered item */
.gallery-item:hover {
  transform: scale(1.05);
  z-index: 10;
}

JavaScript Comparison

Old approach (with JavaScript):

// Form validation - needed event listeners
document.querySelectorAll('input').forEach(input => {
  input.addEventListener('input', function() {
    const formGroup = this.closest('.form-group');
    if (this.checkValidity()) {
      formGroup.classList.add('valid');
      formGroup.classList.remove('invalid');
    } else {
      formGroup.classList.add('invalid');
      formGroup.classList.remove('valid');
    }
  });
});

// Card hover - needed mouseenter/leave listeners
document.querySelectorAll('.card-image').forEach(image => {
  image.addEventListener('mouseenter', () => {
    image.closest('.product-card').classList.add('hovered');
  });
  image.addEventListener('mouseleave', () => {
    image.closest('.product-card').classList.remove('hovered');
  });
});

New approach (pure CSS with :has()):

Zero JavaScript required! All interactions are handled by CSS :has() selector.

Benefits:

  • ✅ No JavaScript event listeners needed
  • ✅ Better performance (no DOM manipulation)
  • ✅ Cleaner HTML (no validation classes)
  • ✅ Easier to maintain (CSS-only changes)
  • ✅ Works with native HTML5 validation API
  • ✅ GPU-accelerated CSS transitions

Container Queries

Widely Supported

Container queries enable responsive design based on container size rather than viewport, perfect for component-based layouts.

Demo 1: Adaptive Profile Card

Resize the containers to see cards adapt from vertical to horizontal layouts. Same component, different widths!

Profile

Jane Smith

Senior Developer

📍 San Francisco 💼 5 years

Narrow Container (Vertical)

Profile

Alex Kim

Lead Designer

📍 New York 💼 8 years

Medium Container (Horizontal)

Profile

Maria Rodriguez

Product Manager

📍 Austin 💼 6 years

Wide Container (Enhanced)

Demo 2: Product Card Grid

Same product card in different grid layouts. Cards automatically adapt to their container width!

Product

Wireless Headphones

Premium noise-canceling audio

$299

1-Column Grid (Minimal)

Product

Smart Watch

Track fitness and notifications

$399

Product

Laptop Stand

Ergonomic workspace upgrade

$79

2-Column Grid (Standard)

Product

Mechanical Keyboard

RGB backlit, premium switches

$159

Product

USB-C Hub

7-in-1 connectivity solution

$49

Product

Webcam 4K

Crystal clear video calls

$129

3-Column Grid (Featured)

Code Example: Profile Card

/* Enable container queries */
.profile-card {
  container-type: inline-size;
}

/* Default: Vertical layout */
.profile-content {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

/* Medium: Horizontal layout */
@container (min-width: 20rem) {
  .profile-content {
    flex-direction: row;
    align-items: center;
  }

  .profile-image {
    width: 80px;
    height: 80px;
  }
}

/* Wide: Enhanced layout */
@container (min-width: 30rem) {
  .profile-name {
    font-size: 1.5rem;
  }

  .profile-meta {
    flex-direction: row;
    gap: 2rem;
  }
}

Code Example: Product Card Grid

/* Enable container queries on each card */
.product-container {
  container-type: inline-size;
}

/* Minimal: Compact layout */
.cq-product-card {
  display: flex;
  flex-direction: column;
}

.cq-card-description {
  display: none;
}

/* Standard: Show description */
@container (min-width: 12rem) {
  .cq-card-description {
    display: block;
  }

  .cq-card-button {
    display: inline-block;
  }
}

/* Featured: Full details */
@container (min-width: 18rem) {
  .cq-card-title {
    font-size: 1.25rem;
  }

  .cq-card-body {
    padding: 1.5rem;
  }
}

Why Container Queries Beat Media Queries

Media queries respond to viewport width:

/* Media query - viewport-based */
@media (min-width: 768px) {
  .card {
    flex-direction: row;
  }
}

/* Problem: All cards change at same viewport width
   regardless of their actual container size */

Container queries respond to container width:

/* Container query - container-based */
@container (min-width: 20rem) {
  .card {
    flex-direction: row;
  }
}

/* Solution: Each card responds to its actual width
   Same component works in sidebar, grid, or main content */

Benefits:

  • ✅ Component responds to container, not viewport
  • ✅ True component reusability across contexts
  • ✅ Works with flexible grids and dynamic layouts
  • ✅ No need for context-specific CSS classes
  • ✅ Components are self-aware of available space
  • ✅ Perfect for component-based frameworks (React, Vue)

Popover API (Modals)

Widely Supported

Native modals and popovers with automatic backdrop, focus management, and accessibility features.

Demo 1: Simple Popover

Click the button to open a popover. Click outside or press Escape to close. Zero JavaScript!

Popover Title

This is a simple popover created with the HTML popover attribute. No JavaScript needed!

Features: automatic focus management, backdrop, Escape key closes it.

Demo 2: Modal Dialog

Opens as a centered modal with backdrop. Light dismiss (click outside to close).

Demo 3: Multiple Popovers

Multiple popovers can be triggered independently. Try opening them!

Popover 1

First popover with useful information.

Popover 2

Second popover with different content.

Popover 3

Third popover demonstrates multiple instances.

Code Example: Basic Popover

<button popovertarget="my-popover">Open</button>
<div popover id="my-popover">
  Popover content
</div>

Code Example: Modal with Close Button

<button popovertarget="modal">Open Modal</button>

<div popover id="modal" class="modal">
  <button popovertarget="modal" popovertargetaction="hide">
    Close
  </button>
  <h3>Modal Content</h3>
  <p>Modal text here</p>
</div>

CSS Styling

/* Style the popover */
[popover] {
  padding: 1.5rem;
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  background: white;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}

/* Backdrop (automatically created) */
[popover]::backdrop {
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(4px);
}

/* Open state */
[popover]:popover-open {
  opacity: 1;
}

JavaScript Comparison

Old approach (with JavaScript):

// Modal required extensive JavaScript
const modal = document.querySelector('.modal');
const openBtn = document.querySelector('.open-btn');
const closeBtn = document.querySelector('.close-btn');

openBtn.addEventListener('click', () => {
  modal.classList.add('open');
  document.body.style.overflow = 'hidden';
  modal.focus(); // Manual focus management
});

closeBtn.addEventListener('click', () => {
  modal.classList.remove('open');
  document.body.style.overflow = '';
});

// Escape key handler
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && modal.classList.contains('open')) {
    modal.classList.remove('open');
  }
});

// Click outside to close
modal.addEventListener('click', (e) => {
  if (e.target === modal) {
    modal.classList.remove('open');
  }
});

New approach (Popover API):

Zero JavaScript! The browser handles opening, closing, focus, Escape key, and backdrop automatically.

Benefits:

  • ✅ No JavaScript required
  • ✅ Automatic focus management
  • ✅ Built-in backdrop
  • ✅ Escape key closes automatically
  • ✅ Light dismiss (click outside)
  • ✅ Accessibility features built-in
  • ✅ Top-layer rendering (no z-index conflicts)

Modern CSS Animations

Widely Supported

Animate display:none and height:auto using @starting-style, transition-behavior, and calc-size().

Demo 1: Animating display Property

Toggle visibility with smooth fade. Notice the element animates from display:none to display:block!

Demo 2: Smooth Accordion

Click to expand. Height animates smoothly from 0 to auto!

What is @starting-style?

@starting-style defines the initial state for entry animations. It works with display transitions to create smooth fade-in effects.

This enables animations that were previously impossible with pure CSS.

What is allow-discrete?

The transition-behavior: allow-discrete property enables transitions for discrete properties like display.

It makes the property wait until the end of the transition to change, making the animation visible.

Why is this useful?

Before these features, you couldn't animate elements appearing/disappearing with display:none.

You had to use visibility:hidden or opacity with workarounds. Now it's native CSS!

Code Example: Animating display

/* Element with display animation */
                            .box {
                                display: block;
                                opacity: 1;
                                transition:
                                opacity 0.4s ease-out,
                                display 0.4s allow-discrete;
                            }

                            /* Hidden state */
                            .box.hidden {
                                display: none;
                                opacity: 0;
                            }

                            /* Starting state for entry animation */
                            @starting-style {
                                .box {
                                    opacity: 0;
                                }
                            }

Code Example: Smooth Accordion

/* Details/Summary accordion */
                            details {
                                transition: height 0.3s ease-out;
                                interpolate-size: allow-keywords;
                            }

                            details::details-content {
                                height: 0;
                                overflow: hidden;
                                transition:
                                height 0.3s ease-out,
                                content-visibility 0.3s ease-out allow-discrete;
                            }

                            details[open] {
                                height: auto;
                            }

                            .details[open]::details-content {
                                /* 3. Set height to auto when opened */
                                height: auto;
                            }

                            /* Smooth content reveal */
                            details[open] > .content {
                                animation: slideDown 0.3s ease-out;
                            }

                            @keyframes slideDown {
                                from {
                                    opacity: 0;
                                    transform: translateY(-10px);
                                }
                                to {
                                    opacity: 1;
                                    transform: translateY(0);
                                }
                            }

Why This Matters

The old problem:

You couldn't animate display: none to display: block because CSS transitions don't work with discrete properties. Elements would just pop in/out.

The old workaround (with JavaScript):

// Had to use setTimeout and remove display manually
function fadeOut(element) {
  element.style.opacity = '0';
  setTimeout(() => {
    element.style.display = 'none';
  }, 300); // Match transition duration
}

function fadeIn(element) {
  element.style.display = 'block';
  element.style.opacity = '0';
  setTimeout(() => {
    element.style.opacity = '1';
  }, 10); // Force reflow
}

The new solution (pure CSS):

  • @starting-style defines entry animation state
  • allow-discrete enables display transitions
  • ✅ No JavaScript timing hacks needed
  • ✅ Smooth, declarative animations

CSS Anchor Positioning

Good Support

Position elements relative to other elements without JavaScript, perfect for tooltips and dropdowns.

Demo Area

Code Example

/* Anchor positioning */
.anchor {
  anchor-name: --my-anchor;
}

.tooltip {
  position: absolute;
  position-anchor: --my-anchor;
  top: anchor(bottom);
}

Modern Observer APIs

Emerging

IntersectionObserver, ResizeObserver, and MutationObserver provide efficient ways to observe DOM changes.

Demo Area

Code Example

// Intersection Observer
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
    }
  });
});

observer.observe(element);

scroll-state() Function

Emerging

The scroll-state() query detects scroll position and stuck state using container queries, enabling scroll-aware styling without JavaScript. This replaces scroll event listeners and IntersectionObserver patterns for common UI behaviors.

Browser Support: Chrome 133+ (scrollable, stuck), Chrome 144+ (scrolled direction detection). Safari 18+ supports scrollable and stuck.

Demo 1: Auto-Hide Header (Scroll Direction)

This header disappears when scrolling down and reappears when scrolling up. Uses scroll-state(scrolled: top/bottom) to detect scroll direction.

Chrome 144+ only - Requires the latest scrolled state detection.

Auto-Hide Header

Scroll down to hide, scroll up to reveal

Scroll to Test Direction Detection

This demo showcases the new scroll-state() function's ability to detect scroll direction. The header at the top of this container will automatically hide when you scroll down and reappear when you scroll up.

Unlike traditional JavaScript scroll listeners that run on every scroll event, scroll-state() is declarative and runs efficiently in CSS. The browser handles all the detection and styling updates without blocking the main thread.

The key CSS container query detects when you're scrolling toward the bottom (scrolled: bottom) or top (scrolled: top). This is perfect for navigation headers that should get out of the way while reading but remain accessible when needed.

Keep scrolling to see the effect in action. Notice how smoothly the header animates in and out as you change scroll direction. The transition is powered by CSS transitions, not JavaScript animation frames.

This pattern is commonly seen in mobile apps and modern web applications where screen space is precious. By hiding the header during downward scrolling, you maximize content visibility while keeping navigation just a quick scroll-up away.

The implementation uses container-type: scroll-state on the scrollable parent, making it a scroll-state container. Then container queries can respond to scroll-state(scrolled: bottom) and scroll-state(scrolled: top) to adjust the header's transform.

One critical gotcha: container queries style descendants, never the container itself. That's why the header needs a wrapper element—the query styles the .header-wrapper, not the .scroll-direction-header directly.

This is significantly more efficient than JavaScript alternatives. No event listeners, no requestAnimationFrame, no manual state tracking. The browser's compositor can handle everything natively.

Performance is excellent even on low-end devices because the styling logic runs off the main thread. JavaScript scroll handlers can cause jank on slower devices, but CSS container queries for scroll-state are smooth by design.

The pattern also respects user preferences. With prefers-reduced-motion, the header transitions are disabled, providing instant show/hide instead of animated transitions.

Accessibility is maintained—keyboard users can still focus and navigate to the header even when it's visually hidden. Screen readers announce it normally. The visual effect doesn't interfere with assistive technology.

Continue scrolling down to see more content and test the hide behavior...

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.

Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.

Now scroll back up to see the header reappear smoothly as you change direction. The effect is instant and responsive, with no delay or performance impact on your browsing experience.

This represents the future of scroll-based UI interactions on the web—pure CSS, declarative, performant, and accessible. No JavaScript libraries required, no complex state management, just clean CSS container queries.

Additional content to enable more scrolling...

More content here to test the scroll behavior thoroughly. The header should remain hidden as long as you're scrolling downward through this content.

And here's the final paragraph. Scroll back up now to see the header slide back into view smoothly!

Demo 2: Back-to-Top Button

A button that appears when you've scrolled down, allowing quick return to the top. Uses scroll-state(scrollable: top) to detect scrollable area.

Chrome 133+, Safari 18+ - Supported in latest Chrome and Safari versions.

Long Article Content

Scroll down through this article to see the back-to-top button appear in the bottom-right corner. The button only shows when there's actually scrollable space above you, ensuring it's only present when useful.

The scroll-state(scrollable: top) query checks if the user can scroll upward from their current position. When true, the button fades in. Click it to smoothly scroll back to the top of this container.

This pattern enhances usability for long-form content like articles, documentation, or product listings. Users can dive deep into content knowing they have an easy way back to the top without manual scrolling.

The button implementation uses position: sticky with bottom positioning, so it stays visible as you scroll. Container queries control its opacity and pointer-events, hiding it when unnecessary and showing it when you need it.

Traditional JavaScript implementations require scroll event listeners, position calculations, and manual threshold checking. With scroll-state(), it's all declarative CSS with no performance overhead.

The button click uses scroll-behavior: smooth on the container (or smooth scrolling JavaScript for older browser support), providing a pleasant animated return to the top rather than a jarring instant jump.

Keep scrolling to see the button appear. You'll notice it fades in smoothly once you've scrolled past a certain threshold, indicating there's content above worth returning to.

This implementation is fully accessible. The button has proper ARIA labeling, keyboard focus states, and respects reduced motion preferences by disabling the fade transition when requested.

Unlike fixed-position buttons that are always visible, this context-aware approach only shows the button when it's actually useful. This reduces visual clutter and keeps the interface clean when the button serves no purpose.

The scroll-state query is highly efficient because it's evaluated by the browser's compositor thread, not the main JavaScript thread. This means smooth performance even on low-powered devices.

More content here to enable scrolling...

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.

Aenean lacinia bibendum nulla sed consectetur. Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Donec sed odio dui. Donec ullamcorper nulla non metus auctor fringilla.

Cras mattis consectetur purus sit amet fermentum. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.

Nullam quis risus eget urna mollis ornare vel eu leo. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.

Look for the back-to-top button in the bottom-right corner. Click it to smoothly scroll back to the top of this article!

Additional scrolling content continues here...

Donec id elit non mi porta gravida at eget metus. Maecenas faucibus mollis interdum. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor.

Final paragraph—the button should be clearly visible now. Test it out by clicking to return to the top!

Demo 3: Sticky Header with Stuck Detection

The header changes style when it becomes stuck to the top. Uses scroll-state(stuck: top) to detect the stuck state.

Chrome 133+, Safari 18+ - Supported in latest Chrome and Safari versions.

Sticky Header

Scroll down to see style change when stuck

Page Content

This demo shows how scroll-state(stuck: top) can detect when a sticky element is "stuck" to its scroll container's edge. The header above starts with a transparent background, but gains a solid background and shadow when it sticks.

Sticky positioning is powerful, but traditionally there was no CSS-only way to style an element differently based on whether it's currently stuck. Developers resorted to JavaScript with IntersectionObserver or scroll listeners to add/remove a "stuck" class.

With scroll-state(stuck: top), the browser natively detects the stuck state and applies styles through container queries. No JavaScript required, no performance overhead, no class name management.

The header has container-type: scroll-state, making it a container query source. When it sticks to the top of its scrollable parent, the stuck state becomes active, triggering the @container query that styles its descendants.

Critical implementation detail: the sticky element must be inside a scrollable parent. If the parent doesn't scroll (no overflow), the stuck state never activates. This demo's wrapper has overflow: auto to enable scrolling and stuck detection.

Another gotcha: container queries style descendants only, never the container itself. That's why the header has a .sticky-header-content wrapper—the query targets that wrapper, not the .sticky-demo-header directly.

This pattern is perfect for navigation headers, table headers, section labels—any UI element that should visually adapt when it reaches a stuck position. The style change provides visual feedback that the element's behavior has changed.

Common stuck state styles include adding shadows (to show elevation), changing background colors (for better contrast), or adjusting padding (for a more compact look). All of these enhance the user experience without JavaScript.

Performance is excellent because stuck detection happens in the compositor thread. The browser's scroll pipeline naturally knows when elements are stuck, so exposing this to CSS queries is efficient and doesn't require expensive layout calculations.

Accessibility is maintained—the header remains keyboard navigable and screen reader accessible regardless of its stuck state. The visual changes are pure enhancement and don't affect document structure or focus order.

Keep scrolling to see more content...

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante venenatis dapibus posuere velit aliquet. Cras mattis consectetur purus sit amet fermentum.

Donec sed odio dui. Etiam porta sem malesuada magna mollis euismod. Nullam id dolor id nibh ultricies vehicula ut id elit. Morbi leo risus, porta ac consectetur ac, vestibulum at eros.

Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Aenean lacinia bibendum nulla sed consectetur.

Curabitur blandit tempus porttitor. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Maecenas sed diam eget risus varius blandit sit amet non magna.

More content continues below...

Sed posuere consectetur est at lobortis. Donec ullamcorper nulla non metus auctor fringilla. Vestibulum id ligula porta felis euismod semper. Praesent commodo cursus magna.

Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Donec id elit non mi porta gravida at eget metus. Fusce dapibus, tellus ac cursus commodo.

Additional scrolling content to demonstrate the sticky behavior...

Nullam quis risus eget urna mollis ornare vel eu leo. Donec ullamcorper nulla non metus auctor fringilla. Cras mattis consectetur purus sit amet fermentum.

Final paragraph of content. The sticky header should remain visible and styled at the top of this scrollable area!

Code Example: Scroll Direction Detection

/* Container with scroll-state detection */
.scroll-direction-demo {
  overflow-y: auto;
  container-type: scroll-state;
  container-name: page-scroll;
  height: 500px;
}

/* Sticky header that will be styled by queries */
.scroll-direction-header {
  position: sticky;
  top: 0;
  z-index: 10;
}

/* Style header wrapper when scrolling down */
@container page-scroll scroll-state(scrolled: bottom) {
  .header-wrapper {
    transform: translateY(-100%);
  }
}

/* Style header wrapper when scrolling up */
@container page-scroll scroll-state(scrolled: top) {
  .header-wrapper {
    transform: translateY(0);
  }
}

Code Example: Back-to-Top Button

/* Scrollable container */
.back-to-top-demo {
  overflow-y: auto;
  container-type: scroll-state;
  container-name: scrollable-area;
  height: 450px;
  position: relative;
}

/* Button hidden by default */
.back-to-top-button {
  position: sticky;
  bottom: 20px;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--transition-base);
}

/* Show button when scrollable upward */
@container scrollable-area scroll-state(scrollable: top) {
  .back-to-top-button {
    opacity: 1 !important;
    pointer-events: auto !important;
  }
}

Code Example: Stuck State Detection

/* Scrollable parent - CRITICAL */
.sticky-demo-wrapper {
  overflow: auto;
  height: 450px;
}

/* Sticky element with scroll-state */
.sticky-demo-header {
  position: sticky;
  top: 0;
  container-type: scroll-state;
  container-name: sticky-header;
  z-index: 10;
}

/* Default unstuck styles */
.sticky-header-content {
  background: transparent;
  color: var(--color-text);
  padding: var(--space-4);
  transition: all var(--transition-base);
}

/* Enhanced styles when stuck */
@container sticky-header scroll-state(stuck: top) {
  .sticky-header-content {
    background: white;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    color: var(--color-primary);
  }
}

CSS vs JavaScript Comparison

Old Way: JavaScript

// Scroll direction detection
let lastScroll = 0;
const header = document.querySelector('.header');

window.addEventListener('scroll', () => {
  const currentScroll = window.pageYOffset;

  if (currentScroll > lastScroll) {
    header.classList.add('hidden');
  } else {
    header.classList.remove('hidden');
  }

  lastScroll = currentScroll;
});

// Back-to-top button
const button = document.querySelector('.back-to-top');

window.addEventListener('scroll', () => {
  if (window.pageYOffset > 300) {
    button.classList.add('visible');
  } else {
    button.classList.remove('visible');
  }
});

// Stuck detection
const observer = new IntersectionObserver(
  ([entry]) => {
    header.classList.toggle('stuck',
      entry.intersectionRatio < 1
    );
  },
  { threshold: [1] }
);

observer.observe(header);
  • Runs on every scroll event
  • Blocks main JavaScript thread
  • Requires manual state tracking
  • Can cause performance issues
  • Needs cleanup on unmount
  • Multiple event listeners

New Way: scroll-state()

/* Scroll direction detection */
.container {
  container-type: scroll-state;
  container-name: page;
}

@container page scroll-state(scrolled: bottom) {
  .header { transform: translateY(-100%); }
}

@container page scroll-state(scrolled: top) {
  .header { transform: translateY(0); }
}

/* Back-to-top button */
.scrollable {
  container-type: scroll-state;
  container-name: area;
}

@container area scroll-state(scrollable: top) {
  .back-to-top {
    opacity: 1;
    pointer-events: auto;
  }
}

/* Stuck detection */
.sticky {
  position: sticky;
  container-type: scroll-state;
  container-name: header;
}

@container header scroll-state(stuck: top) {
  .content {
    background: white;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  }
}
  • Declarative CSS, no JavaScript
  • Runs off main thread
  • No manual state tracking needed
  • Excellent performance
  • No cleanup required
  • Single source of truth

Styleable Select Elements

Emerging

Fully style native select elements using appearance: base-select.

Demo Area

Code Example

/* Styleable select */
select {
  appearance: base-select;
  background: white;
  border: 2px solid #ccc;
  padding: 0.5rem;
  border-radius: 8px;
}