DEV Community

Wahab Shah
Wahab Shah

Posted on • Originally published at csstools.io

Modern CSS Features That Made Me Delete Half My JavaScript

I used to write a lot of JavaScript for things that, in hindsight, were presentation problems. Sticky headers, smooth scrolling, dark mode toggles, image aspect ratios, carousel snapping — all of it lived in JS files, all of it had edge cases, all of it needed testing.

Over the last couple of years I've quietly deleted most of that code. Not because I found better JavaScript — but because CSS got good enough to handle it natively.

Here's what I replaced and how.


1. Dark mode: from class toggling to one CSS variable

The old way:

const btn = document.getElementById('theme-toggle');
btn.addEventListener('click', () => {
  document.body.classList.toggle('dark');
  localStorage.setItem('theme', 
    document.body.classList.contains('dark') ? 'dark' : 'light'
  );
});
Enter fullscreen mode Exit fullscreen mode

Then a parallel set of CSS rules for every component:

.card { background: white; color: black; }
.dark .card { background: #1a1a1a; color: white; }
/* × 50 components */
Enter fullscreen mode Exit fullscreen mode

The new way:

Define your entire color palette as CSS custom properties on :root, and swap a single attribute:

:root {
  --bg: #ffffff;
  --text: #1a1a2e;
  --surface: #f5f5f8;
  --accent: #6c5ce7;
}

[data-theme="dark"] {
  --bg: #0a0a0f;
  --text: #f0f0ff;
  --surface: #13131a;
  --accent: #7c6fff;
}

/* Every component just uses variables — no duplication */
.card {
  background: var(--surface);
  color: var(--text);
}
Enter fullscreen mode Exit fullscreen mode

The JavaScript becomes a single line:

document.documentElement.dataset.theme = 
  document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
Enter fullscreen mode Exit fullscreen mode

That's it. One attribute change cascades through the entire design. This is how the theme toggle on CSSTools.io works, and it covers 30+ tools with zero per-component JS.

Try it live: CSSTools.io has a theme toggle in the sidebar — the whole site flips with a single attribute change.


2. Sticky headers: from scroll listeners to one property

The old way:

const header = document.querySelector('header');
const initialOffset = header.offsetTop;

window.addEventListener('scroll', () => {
  if (window.scrollY >= initialOffset) {
    header.classList.add('is-sticky');
  } else {
    header.classList.remove('is-sticky');
  }
});
Enter fullscreen mode Exit fullscreen mode

This runs on every scroll event, causes layout recalculations, and has a classic bug: offsetTop is wrong if the page layout changes after load.

The new way:

header {
  position: sticky;
  top: 0;
  z-index: 100;
}
Enter fullscreen mode Exit fullscreen mode

Three lines. The browser handles it in the compositor thread — no main thread involvement, no layout thrashing, no scroll listener to clean up. It also works correctly even if the page layout changes dynamically.

The one gotcha: position: sticky only works if no ancestor has overflow: hidden or overflow: auto. If sticky isn't sticking, check your ancestor elements.


3. Smooth scrolling: from 40 lines to 1

The classic smooth-scroll-to-anchor implementation was one of the most copy-pasted JavaScript snippets on the internet:

document.querySelectorAll('a[href^="#"]').forEach(anchor => {
  anchor.addEventListener('click', function(e) {
    e.preventDefault();
    const target = document.querySelector(this.getAttribute('href'));
    const targetPosition = target.getBoundingClientRect().top + window.pageYOffset;
    const startPosition = window.pageYOffset;
    // ... 30 more lines of easing math
  });
});
Enter fullscreen mode Exit fullscreen mode

The new way:

html {
  scroll-behavior: smooth;
}
Enter fullscreen mode Exit fullscreen mode

Every anchor link on the page now animates smoothly. No event listeners. No easing functions. Works with the browser back/forward buttons too, which the JS version usually got wrong.

Add this to respect users who prefer reduced motion:

@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}
Enter fullscreen mode Exit fullscreen mode

4. Responsive image containers: from padding hacks to aspect-ratio

For years, the way to make a responsive video or image container maintain its aspect ratio was the "padding-top hack":

/* The hack — confusing and fragile */
.video-wrapper {
  position: relative;
  padding-top: 56.25%; /* 9 ÷ 16 = 0.5625 */
  height: 0;
}

.video-wrapper iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}
Enter fullscreen mode Exit fullscreen mode

The new way:

.video-wrapper {
  aspect-ratio: 16 / 9;
  width: 100%;
}
Enter fullscreen mode Exit fullscreen mode

Works on any element. Works for images, videos, divs, iframes. You can use aspect-ratio: 1 for squares, 4 / 3 for classic photo ratio, 21 / 9 for cinema. No math, no positioning hacks.


5. Carousels: from Swiper.js to scroll-snap

I've replaced three different carousel library implementations with this pattern:

.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  gap: 16px;
  /* Hide scrollbar visually */
  scrollbar-width: none;
}
.carousel::-webkit-scrollbar { display: none; }

.carousel-item {
  flex-shrink: 0;
  width: 80%;
  scroll-snap-align: center;
}
Enter fullscreen mode Exit fullscreen mode

That's a touch-friendly, momentum-scrolling carousel that always snaps cleanly to an item boundary. No library, no event listeners, ~15 lines of CSS.

What it doesn't do: prev/next buttons with JS. For that you still need a few lines of JavaScript — but the core snapping, momentum, and layout is all CSS.


6. Glassmorphism and gradient effects: from custom code to generators

This one isn't a JS → CSS replacement exactly, but it's where I waste the most time: figuring out the right combination of backdrop-filter, background, border, and box-shadow to get a glassmorphism effect that actually looks good. Or dialing in a gradient that doesn't look like the default browser purple-to-blue.

The visual math is genuinely tedious. So I built tools for it:

Glassmorphism Generator — drag sliders for blur, transparency, border opacity, and shadow. Live preview, copy CSS.

Gradient Generator — multi-stop gradients with angle control, live preview, one-click copy.

Box Shadow Generator — layer multiple shadows, control spread/blur/offset/color, see the result in real time.

These exist because I kept writing the same CSS from memory and getting it slightly wrong every time. Giving them a UI saves the trial-and-error step entirely.


The pattern here

Every example above follows the same shape: JavaScript was being used to solve a presentation problem — something that's fundamentally about how something looks or behaves visually. CSS is better at presentation problems than JavaScript is, for three reasons:

  1. Performance. CSS properties like position: sticky and scroll-behavior run on the compositor thread. They don't block JavaScript execution and don't cause layout recalculations.

  2. Resilience. CSS still applies if your JS bundle fails to load, hits a runtime error, or gets blocked. A sticky header that requires a scroll listener breaks silently. A CSS sticky header never will.

  3. Less surface area. Every line of JavaScript is a test to write, a dependency to update, and a potential runtime error. CSS declarations don't throw exceptions.

The rule of thumb I use now: if I'm about to write JavaScript to change how something looks, I check if CSS can do it first. In 2026, CSS usually can.


→ Explore CSSTools.io — free CSS generators for gradients, shadows, glassmorphism, animations, clip-path, and more.


Tags: css, javascript, webdev, frontend, tutorial

Top comments (0)