2026-03-1410 min readStyleWars Team

10 CSS Features That Replace JavaScript in 2026

Discover 10 modern CSS features that replace JavaScript for scrolling, animations, tooltips, themes, and more. Build faster with CSS-only solutions.

cssjavascriptmodern-csstutorialperformance
Split screen comparison showing JavaScript code on the left being replaced by clean CSS on the right

10 CSS Features That Replace JavaScript in 2026

For years, the default answer to almost any interactive behavior on the web was "use JavaScript." Smooth scrolling? JavaScript. Scroll-triggered animations? JavaScript. Form validation feedback? You guessed it --- JavaScript. But the CSS specification has evolved dramatically, and in 2026 the browser can handle a surprising number of interactions that previously required scripting.

This is not about eliminating JavaScript entirely. JS remains essential for complex application logic, data fetching, and state management. But when you can CSS replace JavaScript for presentational behaviors, you gain real advantages: fewer dependencies, better performance, reduced bundle size, and interfaces that work even when a script fails to load. If you have been following the evolution of CSS as a worthwhile skill, you already know the language is more powerful than most developers give it credit for.

This guide covers ten specific features where CSS without JavaScript is not just possible but preferable. Each section includes a before-and-after code comparison so you can see exactly what changes.

1. Smooth Scrolling With scroll-behavior

Smooth scrolling used to require a JavaScript function that calculated positions and animated the viewport frame by frame. Libraries like smooth-scroll were pulled into projects for this single purpose.

Before (JavaScript):

document.querySelectorAll('a[href^="#"]').forEach(anchor => {
  anchor.addEventListener("click", function (e) {
    e.preventDefault();
    document.querySelector(this.getAttribute("href")).scrollIntoView({
      behavior: "smooth",
    });
  });
});

After (CSS):

html {
  scroll-behavior: smooth;
}

One line. No event listeners, no DOM queries, no third-party library. The browser handles the easing natively, and it respects the user's prefers-reduced-motion setting when paired with a simple media query. Browser support is universal in 2026.

2. Scroll-Driven Animations With animation-timeline

This is arguably the biggest shift in the CSS replace JavaScript conversation. Scroll-driven animations --- progress bars that fill as you scroll, elements that fade in as they enter the viewport, parallax effects --- all of these used to require the Intersection Observer API or scroll event listeners.

Before (JavaScript):

window.addEventListener("scroll", () => {
  const scrollTop = document.documentElement.scrollTop;
  const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
  const progress = scrollTop / scrollHeight;
  document.querySelector(".progress-bar").style.width = `${progress * 100}%`;
});

After (CSS):

@keyframes fill-progress {
  from { width: 0%; }
  to { width: 100%; }
}

.progress-bar {
  animation: fill-progress linear;
  animation-timeline: scroll();
}

The animation-timeline: scroll() property ties any CSS animation directly to the scroll position of a container. No JavaScript runs on every scroll event, no layout thrashing, no jank. You can also use view() to trigger animations when an element enters or exits the viewport, which replaces the most common Intersection Observer pattern. CSS scroll animations are now a first-class feature across all major browsers.

3. Accordions and Toggles With details/summary

Interactive accordions were once built with click handlers that toggled class names and calculated content heights. An entire category of jQuery plugins existed for this.

Before (JavaScript):

document.querySelectorAll(".accordion-header").forEach(header => {
  header.addEventListener("click", () => {
    const content = header.nextElementSibling;
    const isOpen = content.style.maxHeight;
    content.style.maxHeight = isOpen ? null : content.scrollHeight + "px";
    header.classList.toggle("active");
  });
});

After (CSS + HTML):

<details>
  <summary>Click to expand</summary>
  <p>This content is revealed without a single line of JavaScript.</p>
</details>
details summary {
  cursor: pointer;
  font-weight: 600;
}

details[open] summary {
  color: var(--accent);
}

The <details> and <summary> elements are fully semantic, accessible by default, and keyboard-navigable. You can style the open and closed states purely with CSS using the [open] attribute selector. For smooth expand/collapse transitions, check out techniques covered in the CSS smooth height transition to auto guide.

4. Theme Switching With :has() and a Checkbox

Dark mode toggles typically required JavaScript to read a checkbox or button state, toggle a class on the <body>, and persist the choice to localStorage. The :has() selector changes this.

Before (JavaScript):

const toggle = document.getElementById("theme-toggle");
toggle.addEventListener("change", () => {
  document.body.classList.toggle("dark", toggle.checked);
});

After (CSS):

:root {
  --bg: #ffffff;
  --text: #1a1a2e;
}

:root:has(#theme-toggle:checked) {
  --bg: #1a1a2e;
  --text: #e0e0e0;
}

body {
  background: var(--bg);
  color: var(--text);
}

The :has() selector lets a parent element respond to the state of a child anywhere in its subtree. When the checkbox is checked, the custom properties update and every element using those variables re-renders automatically. No class toggling, no DOM manipulation. This is a no JavaScript CSS solution that is both elegant and performant.

5. Form Validation Styles With :valid, :invalid, and :user-invalid

Client-side form validation feedback used to mean attaching input or blur event listeners, checking values against regex patterns, and manually adding error classes. Modern CSS pseudo-classes handle the visual feedback layer entirely.

Before (JavaScript):

const email = document.getElementById("email");
email.addEventListener("blur", () => {
  if (email.validity.valid) {
    email.classList.remove("error");
    email.classList.add("success");
  } else {
    email.classList.remove("success");
    email.classList.add("error");
  }
});

After (CSS):

input:user-invalid {
  border-color: #e74c3c;
  box-shadow: 0 0 0 3px rgba(231, 76, 60, 0.2);
}

input:valid {
  border-color: #2ecc71;
}

input:user-invalid + .error-message {
  display: block;
}

The key here is :user-invalid, which only applies after the user has interacted with the field. Unlike :invalid, it does not flag empty required fields the moment the page loads. Combined with HTML validation attributes like required, pattern, type="email", and minlength, you get robust visual feedback with zero scripting. JavaScript is still needed if you want to prevent form submission or show custom messages, but the styling layer is entirely CSS only.

6. Sticky Headers With position: sticky

Before position: sticky had broad support, developers used scroll event listeners to detect when a header reached the top of the viewport and then toggled a class to fix it in place.

Before (JavaScript):

const header = document.querySelector(".site-header");
const offset = header.offsetTop;

window.addEventListener("scroll", () => {
  if (window.scrollY >= offset) {
    header.classList.add("stuck");
  } else {
    header.classList.remove("stuck");
  }
});

After (CSS):

.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: var(--bg);
}

No scroll listeners, no offset calculations, no class management. The element stays in the document flow until its scroll container reaches the defined threshold, then it sticks. This feature has been well-supported for years but is still underused, with many codebases carrying legacy JavaScript for the same behavior. If you want to practice tightening up patterns like this, the CSS tips and tricks guide is a good starting point.

7. Scroll-Triggered Reveal Effects With animation-timeline: view()

Fade-in-on-scroll effects were the bread and butter of the Intersection Observer API. Libraries like AOS (Animate On Scroll) exist solely for this purpose.

Before (JavaScript):

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add("visible");
      observer.unobserve(entry.target);
    }
  });
}, { threshold: 0.1 });

document.querySelectorAll(".reveal").forEach(el => observer.observe(el));

After (CSS):

@keyframes fade-in {
  from {
    opacity: 0;
    translate: 0 40px;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

.reveal {
  animation: fade-in linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
}

The view() function creates a timeline based on the element's visibility within its scrolling ancestor. The animation-range property gives you precise control over when the animation starts and ends relative to the element entering or exiting the viewport. No observer setup, no callback functions, no library. This is CSS scroll animations at their most practical.

8. Tooltips With the Popover API and CSS Anchor Positioning

Tooltips have historically been one of the most JavaScript-heavy UI patterns: calculate the trigger's position, create a floating element, handle edge detection to prevent overflow, add show/hide listeners, manage z-index stacking. Libraries like Tippy.js and Floating UI exist because doing this correctly is genuinely hard.

Before (JavaScript):

const trigger = document.querySelector(".tooltip-trigger");
const tooltip = document.querySelector(".tooltip");

trigger.addEventListener("mouseenter", () => {
  const rect = trigger.getBoundingClientRect();
  tooltip.style.top = `${rect.top - tooltip.offsetHeight - 8}px`;
  tooltip.style.left = `${rect.left + rect.width / 2 - tooltip.offsetWidth / 2}px`;
  tooltip.style.display = "block";
});

trigger.addEventListener("mouseleave", () => {
  tooltip.style.display = "none";
});

After (CSS + HTML):

<button popovertarget="tip" class="trigger" style="anchor-name: --trigger">
  Hover me
</button>
<div id="tip" popover="hint" anchor="--trigger">
  Tooltip content here
</div>
[popover="hint"] {
  position: fixed;
  position-anchor: --trigger;
  bottom: anchor(top);
  left: anchor(center);
  translate: -50% -8px;
  margin: 0;
  position-try-fallbacks: flip-block;
}

The Popover API handles show/hide semantics, and CSS Anchor Positioning handles the placement logic, including automatic flipping when the tooltip would overflow the viewport. This no JavaScript CSS approach is more robust than most hand-rolled implementations.

9. Carousels With CSS Scroll Snap

Carousels have been one of the most over-engineered components on the web. JavaScript libraries for carousels calculate slide widths, manage touch events, track active indices, and animate transitions. CSS Scroll Snap provides the core mechanic natively.

Before (JavaScript):

let currentIndex = 0;
const track = document.querySelector(".carousel-track");
const slides = document.querySelectorAll(".carousel-slide");

function goToSlide(index) {
  currentIndex = Math.max(0, Math.min(index, slides.length - 1));
  track.style.transform = `translateX(-${currentIndex * 100}%)`;
}

document.querySelector(".next").addEventListener("click", () => goToSlide(currentIndex + 1));
document.querySelector(".prev").addEventListener("click", () => goToSlide(currentIndex - 1));

After (CSS):

.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  gap: 1rem;
}

.carousel > * {
  scroll-snap-align: center;
  flex: 0 0 100%;
}

Users get native swipe support on touch devices, smooth snap behavior on scroll, and keyboard accessibility --- all without JavaScript. For more advanced carousels with autoplay or pagination indicators, you will still need some scripting. But for the core scroll-and-snap behavior that covers the majority of use cases, CSS handles it cleanly.

10. Counters and Dynamic Numbering With CSS Counters

Dynamically numbering ordered lists, section headings, figure captions, or step-by-step guides used to involve JavaScript that traversed the DOM and injected numbers into elements.

Before (JavaScript):

document.querySelectorAll(".step").forEach((step, index) => {
  const label = step.querySelector(".step-number");
  label.textContent = `Step ${index + 1}`;
});

After (CSS):

.steps {
  counter-reset: step-counter;
}

.step {
  counter-increment: step-counter;
}

.step::before {
  content: "Step " counter(step-counter);
  font-weight: 700;
  display: block;
  margin-bottom: 0.5rem;
}

CSS counters automatically track and display numbers without JavaScript. They respect the DOM order, update automatically when elements are added or removed (on re-render), and support nested counters for multi-level numbering like "1.1", "1.2", "2.1". This is one of the oldest CSS only solutions on this list, yet it remains underused.

When You Still Need JavaScript

It is important to be honest about the limits. You still need JavaScript when you need to:

  • Persist state across page loads (localStorage, cookies, server calls)
  • Fetch and render data from APIs
  • Handle complex conditional logic that goes beyond what CSS selectors can express
  • Manage application state in single-page applications
  • Communicate with third-party services (analytics, authentication, payments)

The point is not that CSS can do everything. The point is that CSS can handle the presentational layer of many interactions that developers reflexively reach for JavaScript to solve. Every scroll listener you eliminate is a performance win. Every removed dependency is a smaller bundle. Every CSS without JavaScript solution is one fewer thing that can break when a script fails to load.

Practice These Patterns

The best way to internalize these techniques is to use them under constraints. On StyleWars, every challenge forces you to think in pure HTML and CSS --- no JavaScript allowed. That constraint is exactly what builds the instinct to reach for CSS first. If you want to see how your skills stack up, check the leaderboard or challenge someone directly in a 1v1 verse.

Modern CSS is not a toy language. It is a powerful, declarative system for building interfaces, and in 2026, it handles more interactive behavior than ever before. The developers who recognize this --- and invest in learning these features --- write less code, ship faster, and build more resilient products. Stop defaulting to JavaScript for things the browser already knows how to do.

Sharpen Your CSS Skills

Put what you learned into practice. Try a CSS battle and see how you compare against other developers on the leaderboard.

More from the Blog