Skip to content

Instantly share code, notes, and snippets.

@chengyixu
Created May 9, 2026 08:16
Show Gist options
  • Select an option

  • Save chengyixu/a88bbb5cf0706d0ba652eea2965fef9e to your computer and use it in GitHub Desktop.

Select an option

Save chengyixu/a88bbb5cf0706d0ba652eea2965fef9e to your computer and use it in GitHub Desktop.
Modern CSS Layout Patterns That Replace JavaScript — 2,862 words, complete draft for SitePoint

Modern CSS Layout Patterns That Replace JavaScript

By Wilson Xu | May 5, 2026


The front-end landscape has shifted. For years, the reflex was: need a dynamic layout? Reach for JavaScript. Need an animation triggered by scroll position? IntersectionObserver. Need a custom dropdown? Fifty lines of event handlers and DOM manipulation.

That era is ending. Modern CSS has absorbed so many patterns that previously required JavaScript that a significant chunk of your client-side code may now be dead weight. Browsers have shipped features that handle layout, animation, interactivity, and even state management declaratively -- and they do it faster, with less code, and without blocking the main thread.

This article walks through four categories where CSS now replaces JavaScript, with real patterns you can drop into production today.


1. CSS Grid auto-fill / auto-fit Replaces JavaScript Resize Handlers

The pattern is ubiquitous: render a grid of cards, and when the viewport changes, recalculate how many fit per row. The traditional JavaScript approach looked like this:

// The old way: resize listener + manual column math
window.addEventListener('resize', () => {
  const container = document.querySelector('.grid');
  const cardWidth = 300;
  const columns = Math.floor(container.offsetWidth / cardWidth);
  container.style.gridTemplateColumns = `repeat(${columns}, 1fr)`;
});

This approach has multiple failure modes. The resize event fires at high frequency (requiring debouncing), the calculation doesn't account for gaps, and it breaks entirely if CSS changes the card width. It also runs on the main thread, competing with rendering.

The CSS replacement

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
}

Three lines. No JavaScript. No resize listeners. No debouncing. No column math.

Here is what happens: minmax(280px, 1fr) tells the browser each column must be at least 280px wide but can grow to fill available space. auto-fill tells the browser to place as many columns as fit. When the container shrinks below 280px * 2 + gap, columns collapse from two to one. When it grows past 280px * 3 + gaps, a third column appears. The browser handles all of this natively during layout, outside the main JavaScript thread.

auto-fill vs. auto-fit

These two keywords are easily confused but solve different problems:

  • auto-fill: Creates as many column tracks as can fit, even if some are empty. Use this when you want a fixed maximum number of items per row and don't mind trailing empty tracks.
  • auto-fit: Collapses empty tracks so existing items expand to fill the row. Use this when you want items to stretch when there are fewer than the maximum.
/* auto-fill: empty columns remain, items stay at minimum size */
.grid-fill {
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}

/* auto-fit: empty columns collapse, items stretch */
.grid-fit {
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}

The practical difference: with three items in a container that could hold five columns, auto-fill leaves two empty tracks and the items stay near 250px. auto-fit collapses those empty tracks and the three items stretch to fill the full width.

Real-world pattern: media object grid

Here is a complete card grid that handles any number of items, any viewport width, and any content length -- with zero JavaScript:

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  gap: 2rem;
  padding: 1rem;
}

.card {
  display: grid;
  grid-template-rows: auto 1fr auto;
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  overflow: hidden;
}

.card img {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.card-body {
  padding: 1.25rem;
}

.card-footer {
  padding: 1rem 1.25rem;
  border-top: 1px solid #e2e8f0;
}

/* No media queries needed. No resize handlers. No column calculations. */

The combination of auto-fill on the outer grid and grid-template-rows: auto 1fr auto on each card means every card in a row has the same height, images are uniformly sized, and footers align perfectly -- all without a single line of JavaScript.

When you still need JavaScript

CSS Grid cannot replace JavaScript when:

  • You need to know the current column count for logic (e.g., render a "show more" button only when items wrap).
  • You need masonry layouts with items of varying height that pack tightly (though grid-template-rows: masonry is behind a flag in Firefox).
  • You need to animate items entering or leaving the grid (use the View Transitions API or FLIP animation in JS).

2. Scroll-Driven Animations Replace IntersectionObserver

Animating elements based on scroll position has historically required IntersectionObserver or scroll event listeners:

// The old way: IntersectionObserver for scroll-triggered animation
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible');
      }
    });
  },
  { threshold: 0.1 }
);
document.querySelectorAll('.fade-in').forEach(el => observer.observe(el));

This works but has drawbacks: the observer callback runs on the main thread, creating and managing observers for many elements gets unwieldy, and cleanup on unmount is easy to forget (causing memory leaks in SPAs).

The CSS replacement: scroll-driven animations

The Scroll-Driven Animations spec introduces two primitives:

  1. Scroll Progress Timeline (scroll()): Animation progress is tied to the scroll position of a scroll container.
  2. View Progress Timeline (view()): Animation progress is tied to an element's position relative to the viewport.
.fade-in {
  animation: fadeIn linear;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; transform: translateY(0); }
}

That is it. Every .fade-in element automatically animates as it scrolls into view. No observer setup. No class toggling. No cleanup. The browser handles everything on the compositor thread, off the main thread, at 60fps.

Understanding animation-range

The animation-range property controls when the animation plays within the timeline:

/* Fade in during the entire entry phase */
animation-range: entry 0% entry 100%;

/* Fade in during the first 30% of the entry */
animation-range: entry 0% entry 30%;

/* Stay faded in while the element crosses the viewport, then fade out */
animation-range: entry 0% exit 100%;

/* Animate only while the element covers the viewport */
animation-range: contain 0% contain 100%;

The named ranges are:

  • entry: From the point the element starts entering the scrollport to when it is fully inside.
  • exit: From when the element starts leaving to when it is fully outside.
  • contain: While the element is fully inside the scrollport (must be taller than the viewport).
  • cover: While the element completely covers the scrollport.

Real-world pattern: sticky section reveal

A common pattern in landing pages: sections that reveal content as the user scrolls:

.reveal-section {
  view-timeline-name: --section;
  view-timeline-axis: block;
}

.reveal-section .progress-bar {
  animation: grow linear;
  animation-timeline: --section;
  animation-range: entry 0% entry 100%;
  transform-origin: left;
}

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

.reveal-section .content {
  animation: slideIn linear;
  animation-timeline: --section;
  animation-range: entry 20% entry 60%;
}

@keyframes slideIn {
  from { opacity: 0; transform: translateX(-40px); }
  to   { opacity: 1; transform: translateX(0); }
}

Each section declares a named view timeline with view-timeline-name. Child elements reference that timeline with animation-timeline: --section. The progress bar and content animate at different ranges within the same scroll progression, creating a choreographed reveal -- entirely in CSS.

Scroll progress indicators

Attach animation to the page's scroll position itself:

@keyframes growWidth {
  from { width: 0%; }
  to   { width: 100%; }
}

.scroll-progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  background: #6366f1;
  animation: growWidth linear;
  animation-timeline: scroll(root);
}

The scroll(root) function ties the animation to the root scroller's scroll position. As the user scrolls from top to bottom, the bar grows from 0% to 100% width. This replaces dozens of lines of scroll-event-handler code.

Browser support

As of May 2026, scroll-driven animations are supported in Chrome 115+, Edge 115+, and have experimental support in Firefox behind a flag. Safari support is in development. For production use, wrap in @supports:

@supports (animation-timeline: scroll()) {
  .fade-in {
    animation: fadeIn linear;
    animation-timeline: view();
  }
}

/* Fallback: show content immediately without animation */
@supports not (animation-timeline: scroll()) {
  .fade-in {
    opacity: 1;
    transform: none;
  }
}

3. The Popover API Replaces Custom Dropdown JavaScript

Custom dropdowns, tooltips, and menus have been a persistent source of JavaScript complexity. Every implementation needed to handle:

  • Click-to-open / click-to-close
  • Click-outside-to-close
  • Escape key to close
  • Focus management
  • Z-index and stacking context
  • Positioning relative to a trigger element

The Popover API (popover attribute + popovertarget) handles all of this natively:

<button popovertarget="my-menu">Open Menu</button>
<div id="my-menu" popover>
  <ul>
    <li><a href="/profile">Profile</a></li>
    <li><a href="/settings">Settings</a></li>
    <li><a href="/logout">Log Out</a></li>
  </ul>
</div>

That is the entire implementation. No JavaScript. The browser handles:

  • Light dismiss: clicking outside closes the popover.
  • Escape key: pressing Escape closes the popover.
  • Top layer: the popover renders in the top layer, above everything else, with no z-index management.
  • Focus: focus moves into the popover on open and back to the trigger on close.
  • Semantics: popover implies role="menu"-like behavior.

Styling popovers

The :popover-open pseudo-class lets you style the open state:

[popover] {
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  padding: 0.5rem;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
  opacity: 0;
  transform: translateY(-8px);
  transition: opacity 0.2s, transform 0.2s;
}

[popover]:popover-open {
  opacity: 1;
  transform: translateY(0);
}

The ::backdrop pseudo-element styles the area behind the popover:

[popover]::backdrop {
  background: rgba(0, 0, 0, 0.3);
  backdrop-filter: blur(2px);
}

Manual control via JavaScript (when needed)

For cases where you need programmatic control, the API is minimal:

const popover = document.getElementById('my-menu');

// Show
popover.showPopover();

// Hide
popover.hidePopover();

// Toggle
popover.togglePopover();

// Listen for state changes
popover.addEventListener('toggle', (e) => {
  console.log(e.newState); // 'open' or 'closed'
});

Nested popovers and submenus

The Popover API supports nesting for submenus:

<button popovertarget="main-menu">Menu</button>
<div id="main-menu" popover>
  <button popovertarget="submenu-settings">Settings</button>
  <button popovertarget="submenu-profile">Profile</button>
</div>
<div id="submenu-settings" popover>
  <a href="/account">Account</a>
  <a href="/privacy">Privacy</a>
</div>

Clicking "Settings" opens the submenu. Clicking outside closes everything. Escape walks back up the chain. This replaces hundreds of lines of custom dropdown logic.

Browser support

The Popover API is supported in Chrome 114+, Edge 114+, and Safari 17+. Firefox support is behind a flag. For progressive enhancement:

<!-- Always functional: links inside the popover exist in the DOM -->
<div id="my-menu" popover>
  <ul>
    <li><a href="/profile">Profile</a></li>
  </ul>
</div>

If popover is not supported, the div renders in document flow (still usable, just not as a dropdown overlay). Add a @supports check to apply popover-specific styling only when the feature exists:

@supports (selector([popover]:popover-open)) {
  [popover] {
    position: fixed;
    /* popover-specific positioning */
  }
}

4. The :has() Selector Replaces JavaScript Parent Traversal

The :has() selector -- often called the "parent selector" -- is perhaps the most transformative addition to CSS in years. It allows selecting an element based on whether it contains a specific child or descendant.

Card with image: conditional layout

Without :has(), conditionally styling a card based on whether it contains an image required JavaScript:

// The old way: check if card has an image, then adjust layout
document.querySelectorAll('.card').forEach(card => {
  if (card.querySelector('img')) {
    card.classList.add('has-image');
  }
});

With :has():

.card:has(img) {
  grid-template-columns: 200px 1fr;
}

.card:not(:has(img)) {
  grid-template-columns: 1fr;
}

The card switches to a two-column layout only when it actually contains an image. No JavaScript. No class toggling. The layout responds to the presence or absence of content declaratively.

Form field validation states

:has() shines in forms, where the state of one element affects the styling of its container:

/* Style the field container when its input is focused */
.field:has(input:focus) {
  border-color: #6366f1;
  box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}

/* Style the field when its input is invalid and has been touched */
.field:has(input:user-invalid) {
  border-color: #ef4444;
}

/* Show the error message when the input is invalid */
.field:has(input:user-invalid) .error-message {
  display: block;
}

/* Style the submit button when any field is invalid */
form:has(input:invalid) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}

The last rule is especially powerful: disable the submit button when any form field is invalid, without a single line of JavaScript.

Sibling and ancestor queries

:has() also enables selecting elements based on what comes after or before them:

/* A heading followed by a subtitle */
h2:has(+ .subtitle) {
  margin-bottom: 0.25rem;
}

/* A section that contains nested sections (i.e., a parent section) */
section:has(> section) {
  border-left: 0;
  padding-left: 0;
}

/* A list item that contains a nested list */
li:has(> ul) {
  list-style: none;
}

/* Highlight a table row when it contains a checked checkbox */
tr:has(input:checked) {
  background: #eef2ff;
}

Quantity queries with :has()

Combine :has() with :nth-child for quantity-aware styling:

/* Style a list differently depending on how many items it contains */
ul:has(li:nth-child(1):last-child) {
  /* Exactly 1 item */
  columns: 1;
}

ul:has(li:nth-child(2):last-child) {
  /* Exactly 2 items */
  columns: 2;
}

ul:has(li:nth-child(n+3)) {
  /* 3 or more items */
  columns: 3;
}

This pattern replaces JavaScript that counted child elements and added classes.

Managing layout based on empty states

/* Hide the "no results" message when results exist */
.no-results:has(+ .results-list:not(:empty)) {
  display: none;
}

/* Add a bottom border only when there are items after this one */
.item:has(+ .item) {
  border-bottom: 1px solid #e2e8f0;
}

/* Style a grid differently when it's empty */
.grid:not(:has(*)) {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 200px;
}
.grid:not(:has(*))::before {
  content: 'No items to display';
  color: #94a3b8;
}

Browser support

:has() is supported in all modern browsers: Chrome 105+, Safari 15.4+, Firefox 121+, and Edge 105+. It is safe for production use without fallbacks in most projects. For older browser support, the fallback is straightforward: elements styled with :has() will simply not receive the conditional styling, and the base styles should still produce a functional (if less optimized) layout.


Putting It All Together: A JavaScript-Free Dashboard

Here is a complete example that combines all four techniques -- a data dashboard with zero JavaScript:

<!-- Popover-based user menu -->
<header>
  <h1>Dashboard</h1>
  <button popovertarget="user-menu" class="avatar">WX</button>
  <div id="user-menu" popover>
    <a href="/profile">Profile</a>
    <a href="/billing">Billing</a>
    <a href="/logout">Log Out</a>
  </div>
</header>

<!-- Auto-fill grid of stat cards -->
<main class="stats-grid">
  <div class="stat-card">...</div>
  <div class="stat-card">...</div>
  <!-- Any number of cards; grid adapts automatically -->
</main>

<!-- Scroll-driven progress bar -->
<div class="scroll-progress"></div>
/* 1. Auto-fill grid */
.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
}

/* 2. Scroll-driven progress bar */
.scroll-progress {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  background: #6366f1;
  animation: growWidth linear;
  animation-timeline: scroll(root);
}

@keyframes growWidth {
  to { width: 100%; }
}

/* 3. Popover menu styles */
[popover] {
  border: 1px solid #e2e8f0;
  border-radius: 8px;
  padding: 0.5rem;
  box-shadow: 0 10px 25px rgba(0,0,0,0.15);
}

[popover] a {
  display: block;
  padding: 0.5rem 1rem;
  text-decoration: none;
  color: #1e293b;
  border-radius: 4px;
}

[popover] a:hover {
  background: #f1f5f9;
}

/* 4. :has() for conditional card styling */
.stat-card:has(.trend-up) {
  border-left: 3px solid #22c55e;
}

.stat-card:has(.trend-down) {
  border-left: 3px solid #ef4444;
}

.stat-card:has(.sparkline:empty) {
  grid-template-rows: auto 1fr;
}

This entire dashboard handles responsive layout, scroll tracking, dropdown menus, and conditional styling with zero JavaScript. The browser does all the work at native speed.


When CSS Can't Replace JavaScript (Yet)

Be pragmatic. These are the cases where JavaScript remains necessary:

  1. Client-side data fetching and rendering: CSS cannot call APIs or generate DOM from data.
  2. Complex state machines: Multi-step wizards, drag-and-drop with undo, collaborative editing.
  3. Accessibility when declarative APIs fall short: Focus trapping in modals, ARIA live region updates, roving tabindex in complex widgets. (The <dialog> element + showModal() handles modal focus trapping natively, but popover alone does not.)
  4. Non-trivial animations: Physics-based animations, spring animations, and animations that respond to pointer position still need JavaScript (or the Web Animations API).
  5. Safari gaps: Check caniuse.com for your target audience. scroll-timeline and popover are the two with the most remaining gaps.

The Big Picture

The trend is clear: CSS is absorbing layout, animation, interactivity, and conditional logic that were previously JavaScript's domain. This is good for everyone:

  • Less code to ship: Delete entire utility files of resize handlers, observer setups, and dropdown logic.
  • Better performance: The browser can optimize CSS-driven animations on the compositor thread, off the main thread. Native Popover top-layer handling beats any z-index hack.
  • Fewer bugs: No event listeners to clean up. No race conditions between JS layout calculations and CSS. No forgotten removeEventListener calls.
  • Better developer experience: Declarative code is easier to reason about, easier to maintain, and easier for new team members to understand.

The next time you reach for addEventListener('resize', ...) or new IntersectionObserver(...), pause and ask: can CSS do this now? The answer is increasingly yes.


About the Author

Wilson Xu is a full-stack developer and technical writer focused on front-end performance and modern CSS architecture. He writes about practical patterns that reduce complexity and improve user experience. His work has been published in DigitalOcean, SitePoint, and LogRocket.


Word count: ~2,100 words Target publication: CSS-Tricks Rate: $250

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment