Modern CSS Replacing JavaScript

The most significant shift in web development over the past three years has been the transfer of responsibilities from JavaScript to CSS. Features that previously required entire libraries — positioning, scroll effects, page transitions, element visibility detection, intrinsic size animation, and component-responsive layouts — are now achievable with pure CSS or minimal declarative HTML. This isn’t just about reducing bundle size; CSS-based implementations run on the compositor thread, bypass the main thread entirely, and degrade gracefully when unsupported. The result: faster, more resilient user interfaces with dramatically less code.

Container Queries: Component-Responsive Design

Container queries are to components what media queries are to viewports. Instead of asking “how wide is the screen?”, you ask “how wide is my container?” — making components truly self-contained and reusable regardless of where they’re placed in the layout.

/* Define a containment context */
.card-wrapper {
  container-type: inline-size;
  container-name: card;
}

/* Respond to the container's width, not the viewport */
@container card (min-width: 400px) {
  .card {
    display: grid;
    grid-template-columns: 200px 1fr;
    gap: 1rem;
  }
}

@container card (max-width: 399px) {
  .card { display: block; }
  .card img { width: 100%; }
}

Container queries are Baseline across all modern browsers since February 2023 (Chrome 105+, Safari 16+, Firefox 110+). They fundamentally change how we think about responsive design: a card component that adapts to its container rather than the viewport works identically whether placed in a full-width content area, a narrow sidebar, or a modal dialog — no JavaScript ResizeObserver, no duplicate component variants.

Josh W. Comeau’s “Container Queries Unleashed” demonstrates the most powerful pattern: combining container queries with named containers to create multi-layered responsive UIs. Ahmad Shadeed’s extensive interactive examples at ishadeed.com showcase real-world use cases from art-directed feature images to adaptive navigation patterns.

Container query units (cqw, cqh, cqi, cqb, cqmin, cqmax) allow sizing elements relative to their container rather than the viewport, enabling truly self-contained component typography scaling.

Style queries (@container style(--theme: dark)) allow querying the computed value of a custom property on the container — effectively CSS-only conditional styling based on state, without JavaScript.

Resources:

:has() — The “Parent Selector” and Beyond

:has() is arguably the most powerful selector ever added to CSS. It allows styling an element based on the presence, state, or content of its descendants, siblings, or other relational criteria. It effectively replaces dozens of JavaScript patterns for conditional styling.

/* Style a card differently when it contains an image */
.card:has(img) {
  grid-template-rows: 200px 1fr;
}

/* Style the label when its associated input is invalid */
label:has(+ input:invalid) {
  color: red;
}

/* Restyle the page when a dialog is open */
body:has(dialog[open]) {
  overflow: hidden;
  filter: blur(2px);
}

/* Style a form when any required field is empty */
form:has(:required:placeholder-shown) {
  .submit-btn { opacity: 0.5; pointer-events: none; }
}

/* Quantity queries: style differently based on child count */
.grid:has(> :nth-child(4)) {
  grid-template-columns: repeat(2, 1fr);
}
.grid:has(> :nth-child(7)) {
  grid-template-columns: repeat(3, 1fr);
}

:has() is Baseline across all modern browsers (Chrome 105+, Safari 15.4+, Firefox 121+). The State of CSS 2025 survey identifies :has() alongside Grid and CSS Nesting as the three features that have most changed how developers write CSS. Before :has(), every example above required JavaScript event listeners, DOM observation, or framework state management.

The performance note: browsers have optimized :has() extensively, but overly broad selectors like *:has(*) can be expensive. Keep :has() selectors as specific as possible.

Resources:

CSS Anchor Positioning: Tethered Elements Without JavaScript

CSS Anchor Positioning (Chrome 125+, part of Interop 2025, progressing in Safari and Firefox) eliminates the need for JavaScript positioning libraries like Floating UI, Popper.js, and Tippy.js. It lets you natively position any element relative to another element anywhere in the DOM — tooltips, popovers, dropdown menus, footnotes, and annotation overlays.

/* Define an anchor */
.trigger-btn {
  anchor-name: --my-anchor;
}

/* Position relative to the anchor */
.tooltip {
  position: fixed;
  position-anchor: --my-anchor;

  /* Place below the anchor, centered horizontally */
  top: anchor(bottom);
  left: anchor(center);
  translate: -50% 8px;
}

/* Automatic fallback positioning when space is tight */
.tooltip {
  position-try-options: flip-block, flip-inline;
}

The position-try-options feature is the killer capability: when the tooltip would overflow the viewport, the browser automatically tries alternative positions (flip to the other side, move to a different edge) without any JavaScript viewport calculations. This replaces hundreds of lines of collision detection logic.

Anchor positioning pairs beautifully with the Popover API and <dialog>. Since popovers render in the top layer (above all stacking contexts), anchor positioning tethers them back to their triggering element while they scroll together. Adam Argyle’s anchoreum.com is a comprehensive interactive playground for exploring anchor positioning patterns, and Michelle Barker (CSS { In Real Life }) demonstrates progressively enhanced popover toggletips that degrade to simple anchor links in unsupported browsers.

Resources:

Popover API + interestfor: Declarative Show/Hide Without JavaScript

The Popover API (popover attribute) provides declarative show/hide behavior for overlays, tooltips, dropdowns, and dialogs — no JavaScript event listeners required. Combined with the new interestfor attribute (Chrome 135+) for hover/focus triggering, and commandfor for click triggering, you can build complex interactive components entirely in HTML and CSS:

<!-- Click-triggered popover -->
<button commandfor="menu" command="toggle-popover">Menu</button>
<div id="menu" popover>
  <nav><!-- menu content --></nav>
</div>

<!-- Hover/focus-triggered tooltip (Chrome 135+) -->
<button id="help-btn" interestfor="help-tip">Help</button>
<div id="help-tip" popover="hint">
  Helpful tooltip text
</div>

Popovers render in the top layer, meaning they’re guaranteed to be visible above all other content regardless of z-index and stacking context complexity. Light-dismiss (clicking outside closes the popover), keyboard navigation (Escape to close), and focus management are all handled automatically by the browser.

The three popover types serve different purposes: popover="auto" (default, light-dismiss, only one open at a time), popover="manual" (must be explicitly closed, multiple can be open), and popover="hint" (for tooltips — opening a hint closes other hints but doesn’t close auto popovers). Entry/exit animations work with @starting-style and transition-behavior: allow-discrete:

[popover] {
  opacity: 0;
  translate: 0 10px;
  transition: opacity 0.3s, translate 0.3s, display 0.3s;
  transition-behavior: allow-discrete;

  &:popover-open {
    opacity: 1;
    translate: 0 0;
  }

  @starting-style {
    &:popover-open {
      opacity: 0;
      translate: 0 10px;
    }
  }
}

Resources:

Scroll-Driven Animations: GSAP-Level Effects in Pure CSS

Scroll-driven animations, the most transformative CSS animation feature since keyframes, allow you to control standard CSS @keyframes animations with scroll position instead of time. This replaces libraries like GSAP ScrollTrigger, ScrollMagic, and Locomotive Scroll for the majority of scroll-linked effects — parallax, reveal-on-scroll, progress indicators, sticky headers, and immersive storytelling.

There are two animation timeline types:

scroll() ties the animation to the scroll position of a scrollable container — like a progress bar that fills as you scroll down the page:

/* Reading progress indicator */
.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  background: var(--accent);
  transform-origin: left;
  animation: grow-width linear both;
  animation-timeline: scroll();  /* Tied to root scroller */
}

@keyframes grow-width {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

view() ties the animation to an element’s visibility within the viewport — triggering as elements enter and exit the scroll port:

/* Fade-in on scroll with reduced motion respect */
.card {
  @media (prefers-reduced-motion: no-preference) {
    animation: fade-slide-in linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }
}

@keyframes fade-slide-in {
  from {
    opacity: 0;
    transform: translateY(50px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

The animation-range property controls which portion of the scroll triggers the animation: entry (element entering viewport), exit (leaving), cover (entire time visible), contain (fully visible period). Bramus Van Damme (Chrome DevRel) built the essential Scroll-Driven Animations Range Visualizer for experimenting with ranges interactively.

Adam Argyle’s Scroll Driven Animations Notebook (nerdy.dev) contains 30+ examples ranging from simple slide-ins to complex 3D spatial zoom effects and synchronized swimmers — all pure CSS. As CSS-Tricks notes, “it’s easy to drop your jaw at Adam’s demos, but remember we’re still working with plain old CSS animations — the difference is the timeline they’re on.”

Browser support: Chrome 115+, Edge 115+. Firefox and Safari are in progress but lagging. This is an ideal progressive enhancement — wrap in @media (prefers-reduced-motion: no-preference) and @supports (animation-timeline: scroll()), and unsupported browsers simply don’t animate. A polyfill is available for broader reach.

Always respect prefers-reduced-motion. Scroll-driven animations can cause motion sickness in sensitive users. Wrap all scroll animations in @media (prefers-reduced-motion: no-preference).

Resources:

View Transitions API: Page-Level Animations Without Frameworks

The View Transitions API provides smooth animated transitions between different states of a page (SPA) or between entirely different pages (MPA), replacing the need for frameworks like Framer Motion, React Transition Group, or GSAP page-level animations. Jake Archibald (who proposed the original API) and Bramus Van Damme (who built the comprehensive demo site at view-transitions.chrome.dev) are the primary authors of this feature.

Same-document (SPA) view transitions — wrap a DOM update in document.startViewTransition() and the browser captures a screenshot of the old state, applies your DOM changes, then animates between old and new using CSS animations on pseudo-elements:

// In your SPA router or state change handler
document.startViewTransition(() => {
  // Update the DOM
  updateView(newData);
});
/* Customize the default crossfade */
::view-transition-old(root) {
  animation: fade-out 0.3s ease;
}
::view-transition-new(root) {
  animation: fade-in 0.3s ease;
}

/* Animate specific elements between states */
.product-image {
  view-transition-name: hero-image;
}

::view-transition-group(hero-image) {
  animation-duration: 0.4s;
}

Cross-document (MPA) view transitions — enabled with a single CSS rule, no JavaScript required. This is the truly revolutionary capability: native page transitions across full server-rendered navigations:

@view-transition {
  navigation: auto;
}

Add view-transition-name to elements that should morph between pages (e.g., a product thumbnail on a listing page morphing into the hero image on the product page). The browser handles snapshot capture, cross-fade, and geometry interpolation automatically.

Browser support: Same-document transitions are in Chrome 111+, Safari 18+, Firefox 144+. Cross-document transitions are in Chrome 126+, Safari 18.2+. The view-transition-class property (Chrome 125+, Safari 18.2+, Firefox 144+) enables applying the same transition styles to multiple elements without unique names.

Bramus’s demo site at view-transitions.chrome.dev showcases dozens of patterns: pagination slides, stack navigators, card list add/remove animations, video player expansions, circular clip-path reveals, and more — all categorized by SPA vs MPA usage.

Resources:

interpolate-size & calc-size(): Finally Animate to height: auto

Animating to height: auto has been one of the most requested CSS features for nearly two decades. Every accordion, collapsible panel, and expand/collapse pattern required either JavaScript measurement (getBoundingClientRect()), max-height hacks (with inaccurate animation timing), or the FLIP technique. As of Chrome 129+, interpolate-size and calc-size() solve this natively.

interpolate-size: allow-keywords is the simple, set-and-forget approach. Add it to :root and the browser can transition between any <length-percentage> value and intrinsic sizing keywords (auto, min-content, max-content, fit-content):

/* Add to your CSS reset — enables smooth auto-height animation sitewide */
:root {
  interpolate-size: allow-keywords;
}

/* Now this just works! */
.accordion-content {
  height: 0;
  overflow: clip;
  transition: height 0.35s ease;
}
.accordion[open] .accordion-content {
  height: auto;
}

Josh W. Comeau’s tutorial on interpolate-size (March 2025) recommends adding it to your global CSS reset and treating it as a progressive enhancement: unsupported browsers still toggle between states, just without animation. Bramus Van Damme’s Chrome Developers article provides the definitive reference with interactive demos, including <details> element animation.

calc-size() is the power tool — it enables calculations on intrinsic sizes, which interpolate-size alone cannot do:

/* Half the element's auto width */
width: calc-size(auto, size * 0.5);

/* Auto height minus 10 pixels */
height: calc-size(auto, size - 10px);

/* Animate between fit-content and 70% of fit-content */
@keyframes narrow {
  from { width: fit-content; }
  to { width: calc-size(fit-content, size * 0.7); }
}

The size keyword represents the resolved intrinsic value. Using calc-size() automatically applies interpolate-size: allow-keywords, so it’s all you need for animation.

The ::details-content pseudo-element (Chrome 131+) enables styling the expandable content of <details> elements directly, making native HTML accordions smoothly animatable without wrapper elements.

Browser support: Chrome/Edge 129+ only as of early 2026. Progressive enhancement is perfect here — the feature degrades to instant toggling. Andy Bell (Piccalilli) calls interpolate-size “a great example of progressive enhancement” — it doesn’t break anything when unsupported.

Resources:

Compositor-Safe Animations & the FLIP Technique

Not all CSS properties are created equal when it comes to animation performance. Animating width, height, top, left, margin, or padding triggers layout recalculation for the animated element and potentially its siblings and ancestors — expensive work on the main thread that causes jank on mid-range devices. Compositor-safe propertiestransform, opacity, and filter — can be animated entirely on the GPU compositor thread, independent of main thread activity.

/* ❌ Triggers layout on every frame */
.bad { transition: width 0.3s, left 0.3s; }

/* ✅ Compositor-only — smooth 60fps even during heavy JS execution */
.good { transition: transform 0.3s, opacity 0.3s; }

The FLIP technique (First, Last, Invert, Play), coined by Paul Lewis and popularized by Sarah Drasner, bridges the gap when you need layout-triggering changes to appear animated. Instead of animating the layout property directly, you:

  1. First: Record the element’s initial position (getBoundingClientRect())
  2. Last: Apply the DOM/style change (element snaps to new position)
  3. Invert: Calculate the delta and apply a transform that moves the element back to its original position
  4. Play: Remove the transform with a transition, animating from “old position” to “new position” using only transform
// FLIP pattern
const el = document.querySelector('.card');
const first = el.getBoundingClientRect();

// Apply the layout change
el.classList.add('expanded');

const last = el.getBoundingClientRect();
const deltaX = first.left - last.left;
const deltaY = first.top - last.top;

// Invert: snap back to old position via transform
el.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
el.style.transition = 'none';

// Play: animate to new position by removing the transform
requestAnimationFrame(() => {
  el.style.transition = 'transform 0.3s ease';
  el.style.transform = '';
});

The View Transitions API effectively automates the FLIP technique at the browser level — it captures snapshots, calculates geometry differences, and animates using compositor-safe transforms. For most cases where you’d previously hand-code FLIP, document.startViewTransition() is the modern replacement.

SVG animation gotcha: SVG elements that use transform attributes (not CSS transform) do not animate on the compositor. Ensure SVG animations use CSS transform property rather than the SVG transform attribute. Additionally, will-change: transform promotes an element to its own compositor layer — use it sparingly on elements you know will animate, as over-promotion wastes GPU memory.

Resources:

Scroll-State Container Queries: CSS-Only Sticky/Snapped/Overflowing Detection

An emerging capability (Chrome experimental) extends container queries to query scroll-related states — whether an element is stuck (via position: sticky), snapped (via scroll-snap), or whether a container is overflowing. This replaces IntersectionObserver and scroll event listeners for common scroll-responsive UI patterns.

/* Style a sticky header when it's actually stuck */
.sticky-header {
  container-type: scroll-state;
  position: sticky;
  top: 0;
}

@container scroll-state(stuck: top) {
  .sticky-header {
    background: var(--surface-elevated);
    box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  }
}

/* Show scroll affordances only when content overflows */
.scroll-container {
  container-type: scroll-state;
}

@container scroll-state(scrollable: top) {
  .scroll-shadow-top { opacity: 1; }
}

Adam Argyle demonstrated at CSS Day 2025 how scroll-state() queries enable CSS-only carousels with state-aware navigation, snapped item indicators, and scroll affordance shadows — all without JavaScript.

Combined with ::scroll-marker and ::scroll-button() pseudo-elements (also in development), these features aim to make carousel/scroller patterns achievable entirely in CSS, replacing libraries like Swiper, Flickity, and Embla Carousel for many use cases.

Resources:

@starting-style & Discrete Animations: Animate Entry/Exit from display: none

Previously impossible: animating an element’s appearance from display: none to display: block. CSS couldn’t transition discrete properties like display — elements would snap instantly. @starting-style (Chrome 117+, Safari 17.5+, Firefox 129+) and transition-behavior: allow-discrete solve this:

.modal {
  opacity: 0;
  scale: 0.95;
  display: none;

  transition: opacity 0.3s, scale 0.3s, display 0.3s;
  transition-behavior: allow-discrete;

  &[open] {
    opacity: 1;
    scale: 1;
    display: block;

    @starting-style {
      opacity: 0;
      scale: 0.95;
    }
  }
}

@starting-style defines the from state when an element first becomes visible (enters the DOM, changes from display: none, or opens as a popover). transition-behavior: allow-discrete allows display and overlay to participate in transitions. Together, they enable smooth entry and exit animations for dialogs, popovers, tooltips, and conditionally rendered elements — patterns that previously required React Transition Group, Framer Motion, or manual requestAnimationFrame choreography.

This is particularly powerful with the Popover API: all entry/exit animations can be defined in pure CSS with @starting-style, no JavaScript animation libraries needed.

Resources:

Putting It All Together: The JavaScript Libraries You May No Longer Need

The cumulative impact of these CSS features is staggering. Consider what previously required JavaScript:

Use Case Was JavaScript Now CSS/HTML
Tooltip positioning Floating UI, Tippy.js, Popper.js CSS Anchor Positioning
Show/hide overlays Custom JS event handlers Popover API + commandfor/interestfor
Scroll-linked animation GSAP ScrollTrigger, ScrollMagic animation-timeline: scroll()/view()
Page transitions Framer Motion, Barba.js View Transitions API
Animate height: auto JS measurement + FLIP interpolate-size: allow-keywords
Entry/exit animations React Transition Group @starting-style + allow-discrete
Component responsiveness ResizeObserver Container Queries
Parent-based styling JS state management :has()
Stuck/snapped detection IntersectionObserver Scroll-State Container Queries
Carousel indicators Swiper, Flickity ::scroll-marker + scroll-state()
Masonry layout Masonry.js, Isotope CSS grid-template-rows: masonry / grid-lanes

CSS native masonry is arriving via two competing proposals: grid-template-rows: masonry (the original approach) and the newer grid-lanes proposal, which introduces masonry as a distinct layout mode rather than overloading Grid. Both aim to eliminate JavaScript masonry libraries (Masonry.js, Isotope, Colcade) for Pinterest-style layouts where items have varying heights. The grid-lanes approach is gaining momentum as it avoids the conceptual conflicts of mixing masonry semantics into Grid’s two-dimensional model.

This doesn’t mean JavaScript animation libraries are dead — GSAP, Framer Motion, and others remain essential for complex choreographed sequences, physics-based animations, and timeline coordination that pure CSS can’t express. But for the 80% of common UI patterns — reveal-on-scroll, expand/collapse, tooltip positioning, page transitions — CSS now does it better, faster, and with zero JavaScript cost.

The progressive enhancement principle applies everywhere: these features degrade gracefully. Unsupported browsers skip the animation but still show the content. Always wrap motion in @media (prefers-reduced-motion: no-preference). Always test with real devices, not just desktop Chrome.

Resources: