Native HTML and CSS Features That Replaced JavaScript

For a long time, adding even a small amount of interactivity to a website usually meant reaching for JavaScript.

Need an accordion? JavaScript.

Need a modal? JavaScript.

Need a dropdown or popover? JavaScript.

Need to detect something about the layout or style an element based on what it contains? Often JavaScript again.

That is changing.

Modern HTML and CSS have gained a surprising number of features that cover interactions and layout problems we used to solve with custom scripts or libraries. JavaScript obviously isn’t going anywhere, and there are plenty of things that still require it, but in 2026 I find myself needing it less often for ordinary website interfaces.

I generally prefer native browser features when they solve the problem well enough. They usually mean less code, fewer dependencies, better integration with browser behaviour and less maintenance later.

Here are some of the HTML and CSS features that have replaced JavaScript in parts of my own workflow.

Accordions with <details> and <summary>

Accordions are probably the simplest example.

A few years ago, a basic FAQ section might have needed JavaScript to listen for clicks, toggle classes and change the visibility of the answer.

Something like this:

<button class="faq-button">
  Do you build websites?
</button>

<div class="faq-answer">
  Yes, I do.
</div>
document.querySelectorAll(".faq-button").forEach(button => {
  button.addEventListener("click", () => {
    button.nextElementSibling.classList.toggle("open");
  });
});
.faq-answer {
  display: none;
}

.faq-answer.open {
  display: block;
}

Today, HTML already has an element designed for this:

<details>
  <summary>Do you build websites?</summary>

  <p>
    Yes. I build lightweight websites with a focus
    on performance, accessibility and SEO.
  </p>
</details>

That’s the entire interaction.

The browser handles opening and closing the content, keyboard interaction and the underlying state.

You can style it like any other element:

details {
  border-bottom: 1px solid #ddd;
  padding-block: 1rem;
}

summary {
  cursor: pointer;
  font-weight: 600;
}

details[open] summary {
  margin-bottom: 1rem;
}

You can even react to the open state with CSS using the [open] attribute.

For an FAQ, documentation section or simple disclosure interface, I rarely see a reason to build this behaviour from scratch anymore.

Modals with <dialog>

Modal windows used to be surprisingly complicated.

A typical implementation involved creating an overlay, positioning the modal, trapping focus, listening for the Escape key and preventing interaction with the rest of the page.

The HTML might start like this:

<div class="modal-overlay">
  <div class="modal">
    <h2>Contact me</h2>
    <button class="close">Close</button>
  </div>
</div>

Then JavaScript would have to manage the rest.

HTML now has a native <dialog> element for exactly this purpose.

<button id="open-contact">
  Contact me
</button>

<dialog id="contact-dialog">
  <h2>Contact me</h2>

  <p>
    Send me a message and let's talk about your project.
  </p>

  <form method="dialog">
    <button>Close</button>
  </form>
</dialog>

You still need a tiny bit of JavaScript if you want to open a modal dialog from another button:

const dialog = document.querySelector("#contact-dialog");
const button = document.querySelector("#open-contact");

button.addEventListener("click", () => {
  dialog.showModal();
});

But the browser now handles a large part of the difficult behaviour for you.

You also get the ::backdrop pseudo-element:

dialog {
  border: 0;
  border-radius: 0.75rem;
  padding: 2rem;
  max-width: 32rem;
}

dialog::backdrop {
  background: rgb(0 0 0 / 0.5);
}

So this hasn’t completely eliminated JavaScript, but it has reduced what used to be a fairly complicated component to a few lines.

That is a recurring theme with modern HTML: JavaScript often becomes the glue rather than the thing implementing the entire interface.

Popovers without JavaScript

Popovers are an even better example because they can now be created entirely in HTML.

The Popover API gives the browser native support for things like menus, small overlays, tool palettes and other pieces of content that appear above the page.

A basic popover can be created like this:

<button popovertarget="menu">
  Menu
</button>

<div id="menu" popover>
  <a href="/about/">About</a>
  <a href="/projects/">Projects</a>
  <a href="/contact/">Contact</a>
</div>

No JavaScript is required.

The popovertarget attribute connects the button to the element containing popover, and the browser manages opening and closing it.

By default, an automatic popover can also be dismissed by clicking outside it or pressing Escape. MDN lists the Popover API as Baseline 2025, meaning it is now broadly available in current browsers.

You can style the open state using :popover-open:

[popover] {
  border: 1px solid #ddd;
  border-radius: 0.5rem;
  padding: 1rem;
}

[popover]:popover-open {
  display: grid;
  gap: 0.5rem;
}

There is even a ::backdrop pseudo-element if you need it.

For many small menus and overlays, this removes a surprising amount of JavaScript.

Selecting a parent with :has()

For years, CSS could select children based on their parent, but not the other way around.

If you wanted to style a parent differently depending on what it contained, JavaScript was often involved.

Imagine a form field where you want to highlight the wrapper if the input is invalid:

<div class="field">
  <label for="email">Email</label>
  <input id="email" type="email" required>
</div>

A JavaScript implementation might watch the input and add a class to .field.

Today you can use :has():

.field:has(input:invalid) {
  border-color: red;
}

Or style a card differently if it contains an image:

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

You can also react to interactive states:

.search:has(input:focus) {
  outline: 2px solid currentColor;
}

This is one of those CSS features that seems small until you start using it.

It allows the stylesheet to respond to relationships inside the document that previously often required adding and removing helper classes with JavaScript.

Responsive components with container queries

Responsive design used to revolve almost entirely around viewport width.

You would write something like:

@media (min-width: 800px) {
  .card {
    display: grid;
    grid-template-columns: 12rem 1fr;
  }
}

That works when the component always appears in roughly the same context.

But reusable components don’t necessarily know how wide the browser window is relative to the space they actually receive.

A card might be in:

Full-width content
Sidebar
Grid
Modal
Footer

and need to adapt to its own available width rather than the viewport.

Container queries solve this.

First, define a container:

.cards {
  container-type: inline-size;
}

Then style components based on that container:

@container (min-width: 35rem) {
  .card {
    display: grid;
    grid-template-columns: 10rem 1fr;
    gap: 1.5rem;
  }
}

This isn’t strictly something JavaScript was always used for, but before container queries, developers often reached for scripts such as ResizeObserver when components needed behaviour based on their own dimensions.

Now CSS can solve a large part of that directly.

Automatically sized textareas with field-sizing

Automatically growing textareas are another interface detail that traditionally needed JavaScript.

A simple implementation might listen for input events and repeatedly update the element height:

const textarea = document.querySelector("textarea");

textarea.addEventListener("input", () => {
  textarea.style.height = "auto";
  textarea.style.height = `${textarea.scrollHeight}px`;
});

Modern CSS has a much cleaner solution:

textarea {
  field-sizing: content;
}

That’s it.

The textarea can grow according to its content without manually measuring its scrollHeight.

As of 2026, field-sizing is listed among the features entering Baseline 2026.

You can still constrain it:

textarea {
  field-sizing: content;
  min-height: 6rem;
  max-height: 20rem;
}

This is exactly the kind of feature I like: something that previously took a small script becomes one CSS declaration.

Light and dark themes with light-dark()

Theme switching is another area where JavaScript used to be much more common.

If you simply want your website to respect the user’s system preference, you don’t need JavaScript at all.

The traditional CSS approach is already straightforward:

:root {
  --background: #fff;
  --text: #111;
}

@media (prefers-color-scheme: dark) {
  :root {
    --background: #111;
    --text: #eee;
  }
}

Modern CSS can make this even smaller with light-dark():

:root {
  color-scheme: light dark;

  --background: light-dark(#fff, #111);
  --text: light-dark(#111, #eee);
}

Then:

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

No theme-detection JavaScript is required.

If you want to let visitors manually choose a theme and remember that preference, JavaScript or server-side logic can still be useful. But if the requirement is simply “follow the operating system theme”, CSS already does everything you need.

Smooth scrolling with CSS

Another very small example is smooth anchor scrolling.

It used to be common to see scripts intercept links and animate the page to the target.

For many cases, this now takes one CSS declaration:

html {
  scroll-behavior: smooth;
}

Then normal links work:

<a href="#contact">Contact</a>

...

<section id="contact">
  <h2>Contact</h2>
</section>

There is no need to attach click listeners or calculate scroll positions.

One thing I would add is respect for reduced-motion preferences:

html {
  scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
}

Again, the native version is not only shorter but also keeps the interaction tied to normal HTML links.

Simple horizontal carousels and galleries used to be another place where libraries appeared very quickly.

If all you want is a horizontally scrollable row that snaps items into place, CSS can do most of it:

<div class="gallery">
  <img src="one.webp" alt="">
  <img src="two.webp" alt="">
  <img src="three.webp" alt="">
</div>
.gallery {
  display: flex;
  gap: 1rem;
  overflow-x: auto;

  scroll-snap-type: x mandatory;
}

.gallery > * {
  flex: 0 0 80%;
  scroll-snap-align: start;
}

The browser handles the scrolling and snapping.

This isn’t a complete replacement for every carousel library. If you need autoplay, analytics, complicated controls or unusual looping behaviour, JavaScript may still be justified.

But if the requirement is simply “scroll these cards horizontally and snap them into position”, downloading a carousel library can be excessive.

Aspect ratios without resize calculations

Responsive embeds and image containers used to rely on percentage-padding tricks or JavaScript calculations to maintain their proportions.

Now we have aspect-ratio:

.video {
  aspect-ratio: 16 / 9;
}

Then:

.video iframe {
  width: 100%;
  height: 100%;
}

Or for a square thumbnail:

.avatar {
  width: 6rem;
  aspect-ratio: 1;
  object-fit: cover;
}

It’s a simple property, but it removes another category of layout calculations from JavaScript.

Sticky elements without scroll listeners

Sticky navigation used to be commonly implemented with scroll events.

Something like:

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

For many layouts, CSS can simply do this:

header {
  position: sticky;
  top: 0;
}

The browser handles the scroll position internally.

This is not exactly identical to every JavaScript sticky-header implementation, but if the only requirement is “keep this element visible when it reaches the top”, position: sticky is usually the better tool.

There is no scroll listener firing repeatedly as the visitor moves down the page.

Animating based on scroll position

Scroll animations are one of the more interesting recent examples.

Traditionally, if you wanted an animation to progress as a user scrolled, JavaScript needed to monitor the page position and update styles.

A simplified version might involve:

window.addEventListener("scroll", () => {
  const progress =
    window.scrollY /
    (document.body.scrollHeight - window.innerHeight);

  element.style.transform =
    `scaleX(${progress})`;
});

CSS now has scroll-driven animations.

For example, a page progress bar can be created using:

<div class="progress"></div>
.progress {
  position: fixed;
  top: 0;
  left: 0;

  width: 100%;
  height: 4px;

  transform-origin: left;
  animation: progress linear;
  animation-timeline: scroll();
}

@keyframes progress {
  from {
    transform: scaleX(0);
  }

  to {
    transform: scaleX(1);
  }
}

The browser ties the animation progress directly to the scroll timeline.

There is no need to listen to every scroll event and calculate a percentage manually.

For simple scroll-linked visual effects, this is a much cleaner model.

CSS transitions instead of animation scripts

Even basic animation used to produce more JavaScript than it deserved.

If you simply want an element to change smoothly between states, CSS transitions have long been enough:

.button {
  transform: translateY(0);
  transition:
    transform 150ms ease,
    opacity 150ms ease;
}

.button:hover {
  transform: translateY(-2px);
}

Modern CSS is also becoming better at transitions involving elements that enter and leave the top layer, including popovers.

For example:

[popover] {
  opacity: 0;
  transform: translateY(-0.5rem);

  transition:
    opacity 150ms,
    transform 150ms,
    display 150ms allow-discrete;
}

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

@starting-style {
  [popover]:popover-open {
    opacity: 0;
    transform: translateY(-0.5rem);
  }
}

The Popover API works together with newer CSS features such as @starting-style and discrete transitions, so even opening and closing UI can increasingly be handled declaratively.

This is a good example of HTML and CSS evolving together instead of forcing JavaScript to coordinate everything.

Native form validation

Form validation is another area where JavaScript is often added earlier than necessary.

Suppose you need an email address:

<label for="email">Email</label>

<input
  id="email"
  name="email"
  type="email"
  required
>

The browser already knows that this should contain an email address and that it cannot be empty.

You can add length requirements:

<input
  type="text"
  minlength="3"
  maxlength="50"
  required
>

Ranges:

<input
  type="number"
  min="1"
  max="10"
>

Or patterns:

<input
  type="text"
  pattern="[A-Z]{2}[0-9]{4}"
>

CSS can then respond to validity:

input:user-invalid {
  border-color: red;
}

input:user-valid {
  border-color: green;
}

There are still good reasons to use JavaScript for complex validation, asynchronous checks and better error messaging.

But a surprising number of forms load validation libraries to check rules that the browser already understands.

Native lazy loading

Image lazy loading is another feature that used to require a JavaScript library or IntersectionObserver.

Today:

<img
  src="/images/photo.webp"
  alt="A photograph"
  loading="lazy"
>

The browser decides when it makes sense to load the image.

I wouldn’t use loading="lazy" on an important image at the top of the page, but for images further down an article or gallery, this often removes the need for a custom lazy-loading script entirely.

The same attribute can be used on iframes:

<iframe
  src="https://example.com/embed"
  loading="lazy"
></iframe>

That is a substantial improvement for something that used to involve considerably more code.

Responsive images without JavaScript

Serving different images based on screen size or pixel density also doesn’t need to be handled in JavaScript.

With srcset:

<img
  src="photo-800.webp"
  srcset="
    photo-400.webp 400w,
    photo-800.webp 800w,
    photo-1600.webp 1600w
  "
  sizes="(max-width: 700px) 100vw, 700px"
  alt="A photograph"
>

Or <picture> when the actual crop or format should change:

<picture>
  <source
    media="(max-width: 600px)"
    srcset="photo-mobile.webp"
  >

  <img
    src="photo-desktop.webp"
    alt="A photograph"
  >
</picture>

The browser has more information about the current viewport and device than a custom image script usually does, so letting it make the choice makes a lot of sense.

CSS variables replaced a lot of style manipulation

JavaScript used to be used surprisingly often just to change groups of CSS values.

CSS custom properties make communication between styles much easier:

:root {
  --spacing: 1rem;
  --radius: 0.5rem;
}

.card {
  padding: var(--spacing);
  border-radius: var(--radius);
}

Even when JavaScript genuinely needs to update something, it no longer has to manipulate multiple individual styles.

Instead of:

element.style.backgroundColor = "#111";
element.style.color = "#fff";
element.style.borderColor = "#333";

you can often change a single custom property:

document.documentElement.style.setProperty(
  "--theme",
  "dark"
);

or toggle one attribute/class and leave the actual design logic in CSS.

This doesn’t replace JavaScript, but it reduces how much presentation logic needs to live inside it.

CSS can respond to user preferences

Another category of JavaScript that has become unnecessary is detecting certain user or device preferences.

For reduced motion:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms;
    animation-iteration-count: 1;
    scroll-behavior: auto;
  }
}

For dark mode:

@media (prefers-color-scheme: dark) {
  body {
    background: #111;
    color: #eee;
  }
}

For higher contrast preferences:

@media (prefers-contrast: more) {
  a {
    text-decoration-thickness: 0.15em;
  }
}

You don’t need to query these values with JavaScript just to adjust the visual presentation.

CSS was designed to handle exactly this kind of environment-dependent styling.

The pattern I prefer

When I’m deciding how to build a component now, I usually think about it roughly in this order:

HTML
 ↓
Can HTML already do it?

CSS
 ↓
Can CSS provide the behaviour or layout?

Small JavaScript
 ↓
Can a few lines connect the pieces?

Library / framework
 ↓
Is the problem actually complicated enough to need one?

A lot of older frontend development tended to start much lower down that list.

Something needed to open and close, so we added a JavaScript component.

Something needed to react to the viewport, so we added a listener.

Something needed to grow with its content, so we measured it.

Something needed to stay at the top of the screen, so we watched scrolling.

Browsers increasingly handle these things themselves.

Native doesn’t always mean better

I don’t think the lesson here should be “never use JavaScript.”

That would be just as unhelpful as using it for everything.

JavaScript remains essential for actual application logic, fetching data, complex state, real-time interaction, editing tools, maps, games and countless other things.

There are also cases where native browser features don’t provide enough control, where older browser support matters, or where the user experience genuinely benefits from a custom implementation.

The point is simply that JavaScript shouldn’t automatically be the first tool we reach for.

Before writing:

document.querySelector(...)

it is worth checking whether HTML or CSS learned how to solve the problem while we weren’t paying attention.

Browsers have become much more capable

What I find interesting is that the simplest version of web development has become more powerful.

We can now build surprisingly capable interfaces from HTML and CSS alone:

Accordion        → <details>
Modal            → <dialog>
Popover          → popover
Parent selection → :has()
Responsive UI    → container queries
Growing inputs   → field-sizing
Themes           → light-dark()
Sticky elements  → position: sticky
Carousels        → scroll snap
Scroll effects   → scroll-driven animations
Lazy images      → loading="lazy"
Responsive images→ srcset / picture
Validation       → HTML form attributes

A few of these still need small amounts of JavaScript in certain situations, but the amount of code required is dramatically smaller than it used to be.

And this isn’t only about saving a few kilobytes.

Native features usually mean fewer dependencies to update, fewer edge cases to implement yourself and more behaviour delegated to the browser.

That is generally a trade I am happy to make.

Start with the platform

My default approach today is simple: start with HTML, use CSS for as much presentation and interaction as it can reasonably handle, and add JavaScript when there is an actual reason for it.

Not because I have anything against JavaScript, but because code that doesn’t need to exist is very easy to maintain.

The web platform has become capable enough that many things we still think of as “JavaScript components” aren’t JavaScript problems anymore.

Sometimes the best way to improve a script is to delete it.