Skip to main content

Command Palette

Search for a command to run...

CSS Has Won: 10 Things You No Longer Need JavaScript For in 2026

Scroll animations, tooltip positioning, modal transitions, carousels, nesting, and more all native CSS now. Here's the JS you can delete.

Updated
9 min read
CSS Has Won: 10 Things You No Longer Need JavaScript For in 2026

There's a quiet revolution happening and nobody's writing angry tweets about it.

While the JavaScript ecosystem spent the last two years arguing about React Server Components, Bun vs Deno, and whether Tailwind is a sin, CSS just shipped. Silently. Feature after feature, landing cross-browser, handling things we've been importing 40KB npm packages for.

I went through my last three projects and counted the JavaScript I could delete because CSS now does it natively. The number was uncomfortable. Entire libraries. Hundreds of lines of event listeners. IntersectionObserver setups. ResizeObserver wrappers. Positioning math. All of it, replaced by a few lines of CSS.

Here are 10 things you can stop using JavaScript for today. Each one includes the JS you're probably still writing and the CSS that replaces it.


1. Scroll-Driven Animations

What you can delete: GSAP ScrollTrigger, Framer Motion scroll effects, any custom IntersectionObserver + CSS class toggle for reveal animations.

The old way: set up an observer, watch elements enter the viewport, add a class, clean up:

// 15+ lines of JS you don't need anymore
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
      observer.unobserve(entry.target);
    }
  });
}, { threshold: 0.2 });

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

The new way: pure CSS, runs on the compositor thread, buttery smooth even under heavy load:

.reveal {
  animation: fade-up linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
}

@keyframes fade-up {
  from { opacity: 0; transform: translateY(30px); }
  to   { opacity: 1; transform: translateY(0); }
}

That's it. No JavaScript. No observer. No cleanup. The browser ties animation progress directly to scroll position. Works for parallax effects, progress bars, reveal sequences, anything you used to wire up with scroll listeners.

Browser support: Chrome, Edge, Safari. Firefox in active development and part of Interop 2026. Use @supports (animation-timeline: view()) for progressive enhancement.


2. Tooltip & Dropdown Positioning

What you can delete: Popper.js, Floating UI, any React wrapper that calculates "should this tooltip go above or below?"

The old way: import a library, create an instance, handle overflow and flipping:

import { computePosition, flip, offset, shift } from '@floating-ui/dom';

computePosition(trigger, tooltip, {
  placement: 'bottom',
  middleware: [offset(8), flip(), shift({ padding: 5 })],
}).then(({ x, y }) => {
  Object.assign(tooltip.style, { left: `\({x}px`, top: `\){y}px` });
});

The new way: CSS Anchor Positioning, with automatic fallback when there's no space:

.trigger {
  anchor-name: --my-trigger;
}

.tooltip {
  position: fixed;
  position-anchor: --my-trigger;
  position-area: bottom;
  margin-top: 8px;
  position-try-fallbacks: flip-block, flip-inline;
}

The browser handles viewport collision detection natively. No JavaScript. No resize listeners. No "recalculate on scroll." The tooltip flips when it runs out of space, exactly like Floating UI's flip() middleware, but free.

Browser support: Chrome, Edge. Safari in development, part of Interop 2026. This is the one to watch.


3. Modal & Popover Entry Animations

What you can delete: requestAnimationFrame hacks, setTimeout delays, animation libraries for "animate from display: none."

The oldest frustration in CSS: you can't transition from display: none to display: block. Elements skip straight to visible with no animation. Every modal library in existence exists partly because of this.

@starting-style fixes it:

dialog[open] {
  opacity: 1;
  transform: scale(1);
  transition: opacity 0.3s, transform 0.3s,
              display 0.3s allow-discrete,
              overlay 0.3s allow-discrete;
}

@starting-style {
  dialog[open] {
    opacity: 0;
    transform: scale(0.95);
  }
}

/* Exit animation */
dialog:not([open]) {
  opacity: 0;
  transform: scale(0.95);
}

Combined with the Popover API (popover attribute), you can build fully animated popovers, dialogs, and tooltips with zero JavaScript for the animation logic. The allow-discrete keyword lets display itself participate in the transition.

Browser support: Baseline across all major browsers.


4. Parent-State Styling With :has()

What you can delete: Any JavaScript that toggles a class on a parent based on a child's state.

Remember adding event listeners to form inputs so you could style their parent container when focused? Or toggling a has-error class on a <div> when a child input was invalid?

// JS we wrote for YEARS to do this
input.addEventListener('focus', () => {
  input.closest('.form-group').classList.add('focused');
});
input.addEventListener('blur', () => {
  input.closest('.form-group').classList.remove('focused');
});

Now:

.form-group:has(input:focus) {
  border-color: var(--brand-blue);
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}

.form-group:has(input:invalid) {
  border-color: var(--error-red);
}

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

/* Disable submit if any required field is empty */
form:has(:required:placeholder-shown) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}

That last one is wild: a submit button that disables itself purely in CSS based on whether required fields are empty. No state management. No JavaScript validation. Pure CSS.

Browser support: Baseline across all major browsers since December 2023. Use it today.


5. Responsive Component Sizing

What you can delete: ResizeObserver wrappers, JavaScript-based responsive logic, any component that checks its own width.

Container Queries let components respond to their container's size, not the viewport. Your sidebar component can be compact at 250px and expanded at 400px, regardless of screen size.

.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}

@container sidebar (min-width: 400px) {
  .nav-item {
    flex-direction: row;
    gap: 12px;
  }
  .nav-label { display: block; }
}

@container sidebar (max-width: 399px) {
  .nav-item {
    flex-direction: column;
    gap: 4px;
  }
  .nav-label { display: none; }
}

No ResizeObserver. No useElementSize() hook. No "check width on mount and window resize." The component just adapts.

Browser support: Baseline widely available. Use it everywhere.


6. CSS Nesting (Goodbye Sass for Most Projects)

What you can delete: Sass/SCSS as a dev dependency, if you were only using it for nesting and variables.

/* Native CSS nesting, no preprocessor needed */
.card {
  padding: 1.5rem;
  border-radius: 12px;

  & h2 {
    font-size: 1.25rem;
    margin-bottom: 0.5rem;
  }

  & p {
    color: var(--text-muted);
  }

  &:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  }

  @media (width < 768px) {
    padding: 1rem;
  }
}

Between native nesting, custom properties (CSS variables), color-mix(), oklch() colors, and @layer for cascade control, Sass's remaining unique value proposition is shrinking fast. If you were only using Sass for nesting and $variables, you can delete it today.

Browser support: Baseline across all major browsers.


7. Auto-Resizing Textareas

What you can delete: Every autosize library and every onInput handler that calculates scrollHeight.

// The classic hack we've all written
textarea.addEventListener('input', () => {
  textarea.style.height = 'auto';
  textarea.style.height = textarea.scrollHeight + 'px';
});

Now:

textarea {
  field-sizing: content;
}

One line. The textarea grows with its content. No JavaScript. No height recalculation. No layout thrashing.

Browser support: Chrome, Edge. Safari and Firefox in development.


8. Scroll-Snap Carousels

What you can delete: Swiper.js, Embla Carousel (for basic use cases), any JS-based carousel with dots and arrows.

CSS is shipping native carousel primitives with ::scroll-button() and ::scroll-marker() pseudo-elements:

.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;

  &::scroll-button(left)  { content: "←"; }
  &::scroll-button(right) { content: "→"; }
}

.carousel > * {
  scroll-snap-align: center;

  &::scroll-marker {
    content: "";
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: gray;
  }
}

The browser handles snap points, button logic, indicator dots, keyboard navigation, and disabled states when you reach the end. You just style them.

Browser support: Chrome (most features). Still early, so keep your JS carousel for production, but start experimenting.


9. Dark Mode Toggle Logic

What you can delete: Half your dark mode JavaScript. The light-dark() function lets you define light and dark values inline:

:root {
  color-scheme: light dark;
}

body {
  background: light-dark(#ffffff, #0a0a0f);
  color: light-dark(#1a1a1a, #e5e5e5);
}

.card {
  background: light-dark(#f8f8f8, #1a1a1a);
  border: 1px solid light-dark(#e0e0e0, #2a2a2a);
}

No more maintaining two sets of CSS variables. No more .dark class toggles with JavaScript. The browser respects the user's system preference by default, and color-scheme on a parent element can override it for a section of the page.

Browser support: Baseline across all major browsers.


10. View Transitions (Page Animations Without a Framework)

What you can delete: Page transition libraries, custom SPA animation wrappers.

Same-document view transitions are Baseline. Cross-document (MPA) transitions are landing:

/* Cross-page transitions. no JavaScript at all */
@view-transition {
  navigation: auto;
}

::view-transition-old(root) {
  animation: fade-out 0.25s ease;
}

::view-transition-new(root) {
  animation: fade-in 0.25s ease;
}

For SPAs, the JavaScript is minimal. Just wrap your DOM update:

document.startViewTransition(() => {
  // your state/DOM change here
});

CSS handles all the animation choreography. Named transitions let you animate specific elements (like a product image "flying" from a list to a detail page) with just a view-transition-name property.

Browser support: Same-document transitions are Baseline. Cross-document transitions in Chrome/Edge, with Safari and Firefox working on it via Interop 2026.


The Uncomfortable Truth

Here's what all of this adds up to: the browser is absorbing functionality that used to require libraries. And every feature that moves into CSS runs faster (native code vs interpreted JavaScript), ships smaller (zero bundle impact), and is more reliable (browser-tested, not library-maintained).

I'm not saying JavaScript is dead, obviously. But a significant chunk of the JS we write in frontend codebases isn't "application logic." It's compensating for things CSS couldn't do. Positioning. Animation. Responsive behavior. Entry transitions. Scroll detection.

CSS can do all of those now. Not experimentally. Not "in a future spec." Now, in 2026, shipping cross-browser via Interop 2025 and 2026.

The developers who keep importing Floating UI for tooltips that CSS Anchor Positioning handles natively, or setting up IntersectionObservers for reveal animations that animation-timeline: view() does in one line, they're writing legacy code. Today. In a brand new project.

Audit your package.json. Check your bundle size. See how much of it is doing a job that CSS now handles for free.

You might be surprised.


If this made you rethink a dependency, hit that reaction button. Got a CSS trick that replaced your JS? Share it in the comments, I'm building a collection.