DEV Community

Veranika Kasparevych
Veranika Kasparevych

Posted on

9 new CSS features that are quietly firing your JavaScript.

So Chrome dropped CSS Wrapped 2025 and I've been spelunking through all 22 features like a raccoon in a recycling bin. And I noticed something: a suspicious number of them exist for one reason only – to take over a big chunk of the UI JavaScript you've been dutifully writing since forever.

No more onclick handlers to open a dialog. No more 40-line carousel scripts. No more IntersectionObserver just to highlight a menu item. The web platform looked at all of that, sighed, and said "I got this."

Here are 9 features that replace a surprising amount of UI JavaScript, each with the old JS way next to the shiny new CSS way.

Small honesty note before we start: a few of these (command, commandfor, closedby, interestfor) are technically HTML attributes, not CSS proper. They shipped alongside the CSS stuff under the "CSS Wrapped" banner, so I'm bundling them in. And these are fresh – some are Chromium-only right now, and a couple are still behind flags or Canary-only. Support is moving fast, so check the Baseline widget or Can I Use for today's status.

All demos feature cats. No further justification will be provided.


1. Open a dialog without JavaScript – command & commandfor

The classic way to open a <dialog> is to grab it in JS and call showModal(). Which means an onclick, which means JavaScript, which means you've left the cozy world of markup.

The old way (JS):

<button onclick="document.querySelector('#adopt').showModal()">
  Adopt Mr. Whiskers
</button>
<dialog id="adopt">Are you a worthy human?</dialog>
Enter fullscreen mode Exit fullscreen mode

The new way (just HTML):

<button commandfor="adopt" command="show-modal">Adopt Mr. Whiskers</button>
<dialog id="adopt">Are you a worthy human?</dialog>
Enter fullscreen mode Exit fullscreen mode

commandfor points at an ID (just like a label's for), and command takes a built-in action. The buttons mirror the JS methods you already know:

  • show-modaldialog.showModal()
  • closedialog.close()
  • show-popoverel.showPopover()
  • hide-popoverel.hidePopover()
  • toggle-popoverel.togglePopover()

Nice bonus: this one already went Baseline – it works in Chrome, Firefox, and Safari today, so you can actually ship it.

And if you want your own action, prefix it with -- and catch it with a command event (this part still needs a little JS, since custom logic is, well, logic):

<img id="cat" src="cat.jpg" alt="Cat" />
<button commandfor="cat" command="--throw-confetti">🎉</button>
Enter fullscreen mode Exit fullscreen mode
document.querySelector('#cat').addEventListener('command', (e) => {
  if (e.command === '--throw-confetti') {
    // make it rain catnip
  }
});
Enter fullscreen mode Exit fullscreen mode

Zero JS for the common dialog-opening cases. The onclick graveyard grows.


2. Close that dialog by clicking outside – closedby

You know the drill: open a modal, then write a click listener on the backdrop AND a keydown listener for the Esc key, just so users can dismiss it like a civilized popover. Popovers got this for free. Dialogs didn't… until now.

The old way (JS):

dialog.addEventListener('click', (e) => {
  if (e.target === dialog) dialog.close();
});
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') dialog.close();
});
Enter fullscreen mode Exit fullscreen mode

The new way (one attribute):

<button commandfor="adopt" command="show-modal">Adopt</button>
<dialog id="adopt" closedby="any">
  Mr. Whiskers is reviewing your application...
</dialog>
Enter fullscreen mode Exit fullscreen mode

closedby lets you explicitly control how a dialog can be dismissed, with three values:

  • any – clicking the backdrop or pressing Esc closes it, just like popover="auto"
  • closerequest – Esc (or a platform close gesture) closes it
  • none – neither; you close it yourself via close()

One gotcha worth knowing: if you don't set closedby, the behavior depends on how the dialog was opened. A dialog shown with showModal() already closes on Esc (it behaves like closerequest), while a non-modal one behaves like none. So the real win here is closedby="any" – that's the click-outside-to-close behavior you used to hand-roll.

Two listeners gone. One attribute. Chef's kiss. 👌


3. Tooltips on hover, no JavaScript – interestfor + popover=hint

Hovercards and tooltips are everywhere, and they're always a pile of JS: mouseenter, mouseleave, focus, blur, a timeout so it doesn't flicker, and some sad fallback for touch users. The new interestfor attribute does a lot of that dance natively.

The old way (JS): ...honestly too long to paste. You know it. Timeouts, four event listeners, a setTimeout you forgot to clear.

The new way:

<button interestfor="whiskers-card">Mr. Whiskers</button>

<div id="whiskers-card" popover="hint">
  7 years old · loves naps · mild distrust of vacuums
</div>
Enter fullscreen mode Exit fullscreen mode

interestfor fires when the user "shows interest" – hovering with a mouse OR focusing with a keyboard – and it works on <a> tags too, not just buttons. Pairing it with the new popover="hint" is the magic combo: hint popovers are ephemeral and don't close your other open popovers, so layered UI (a tooltip on top of a menu) just works.

Want to tune the flickery feeling? There's a CSS property for that!

That's a huge chunk of tooltip boilerplate – hover, focus, timers, touch handling – replaced for simple hover/focus hints. (Full tooltip libraries still earn their keep for collision detection, viewport flipping, and interactive tooltips, so don't rip yours out just yet.)


4. A <select> you can actually style – appearance: base-select

Be honest: how many times have you reached for a JS dropdown library (or built your own with a <div> soup, ARIA roles, and keyboard handlers) just because the native <select> couldn't be styled? Those days are ending.

The new way (CSS):

select,
::picker(select) {
  appearance: base-select;
}

::picker(select) {
  border: 1px solid #ddd;
  border-radius: 12px;
  padding: 4px;
}

option {
  display: flex;
  gap: 0.6rem;
  padding: 0.5rem;
  border-radius: 8px;
}

option:checked { 
  background: #efecfb; 
}
Enter fullscreen mode Exit fullscreen mode

That single switch opts the <select> into a fully stylable mode. Now you can style the button, the dropdown list (::picker(select)), and every <option> – colors, fonts, spacing, even animations. The picker renders in the top layer, so it won't get clipped by overflow: hidden parents, and the browser still handles flipping it when there's no room below.

Bonus: in this new customizable <select> model (once you've opted in with base-select), options can contain rich HTML – like a little cat avatar next to each name:

<select>
  <button>
    <selectedcontent></selectedcontent>
  </button>
  <option value="whiskers">
    <span class="avatar">🐱</span>
    <span class="name">Mr. Whiskers</span>
    <span class="description">Senior nap consultant</span>
  </option>
  <option value="pixel">
    <span class="avatar">🐈‍⬛</span>
    <span class="name">Pixel</span>
    <span class="description">Keyboard warmth specialist</span>
  </option>
</select>
Enter fullscreen mode Exit fullscreen mode

That <selectedcontent> element inside the button is new too: it clones the chosen option's markup into the button, so the selected state shows the same rich content. Want the avatar in the button but not the long description? Just hide it:

selectedcontent .description {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

Native dropdown. Custom looks. No library for the common cases. We've waited years for this one.


5. A carousel with zero JavaScript – ::scroll-button() & ::scroll-marker()

Carousels are the reason half of us learned to hate front-end. Scroll listeners, index tracking, dot navigation, disabled arrow states, accessibility... It's a whole weekend. CSS now generates the buttons and dots for you.

The new way:

<ul class="cat-gallery">
  <li><img src="whiskers.jpg" alt="Mr. Whiskers judging you" /></li>
  <li><img src="pixel.jpg" alt="Pixel asleep on a keyboard" /></li>
  <li><img src="mochi.jpg" alt="Mochi making biscuits" /></li>
</ul>
Enter fullscreen mode Exit fullscreen mode
.cat-gallery {
  display: flex;
  gap: 1rem;
  list-style: none;
  padding: 0;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}

.cat-gallery > li {
  flex: 0 0 100%;
  scroll-snap-align: center;
}

.cat-gallery::scroll-button(left) { 
  content: "⬅" / "Previous cat"; 
}

.cat-gallery::scroll-button(right) { 
  content: "➡" / "Next cat"; 
}

.cat-gallery { 
  scroll-marker-group: after; 
}

.cat-gallery > li::scroll-marker {
  content: "";
  width: 1em;
  height: 1em;
  border: 1px solid currentColor;
  border-radius: 50%;
}
.cat-gallery > li::scroll-marker:target-current {
  background: currentColor;
}
Enter fullscreen mode Exit fullscreen mode

The ::scroll-button() pseudo-elements are real, focusable buttons that the browser automatically disables when scrolling isn't possible in that direction (one of the most annoying things to do by hand). The content: "⬅" / "Previous cat" syntax even gives you an accessible label after the slash.

The ::scroll-marker dots behave like anchor links – click one and you jump to that cat – and :target-current styles whichever one is in view. (You might still reach for JS for autoplay, lazy-loading, or analytics.)

And here's an underrated part: because these are real, browser-native controls, they inherit keyboard support and accessibility semantics that hand-rolled JS carousels usually have to reimplement from scratch.


6. Scroll-spy navigation, hold the JavaScript – scroll-target-group

You know that nav where the current section highlights as you scroll? The "scroll-spy"? That's a clear example of the IntersectionObserver boilerplate. Now it's two CSS lines on a normal list of anchor links.

The old way (JS):

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    const link = document.querySelector(`a[href="#${entry.target.id}"]`);
    if (entry.isIntersecting) link.classList.add('active');
    else link.classList.remove('active');
  });
});
sections.forEach((s) => observer.observe(s));
Enter fullscreen mode Exit fullscreen mode

The new way (CSS):

<nav class="menu">
  <a href="#kittens">Kittens</a>
  <a href="#seniors">Distinguished Seniors</a>
  <a href="#chonkers">Absolute Units</a>
</nav>
<section id="kittens"></section>
<section id="seniors"></section>
<section id="chonkers"></section>
Enter fullscreen mode Exit fullscreen mode
html { 
  scroll-behavior: smooth; 
}

section { 
  scroll-margin-top: 4rem; 
}

.menu {
  position: sticky;
  top: 0;
  background: white;
  scroll-target-group: auto;
}

.menu a:target-current {
  color: hotpink;
  font-weight: bold;
}
Enter fullscreen mode Exit fullscreen mode

scroll-target-group: auto turns your anchor links into connected scroll-markers, and :target-current styles whichever link points at the section you're currently looking at.


7. Know if something is stuck or snapped – scroll-state queries

Ever wanted to style a sticky header differently once it actually sticks? Or fade the non-active items in a snap-scroller? Traditionally: scroll event listeners, getBoundingClientRect(), throttling, tears. Now CSS can just ask.

The new way (CSS): declare a scroll-state container, then query it with a container query stuck, snapped, scrollable and scrolled. Note that which element becomes the container depends on the state you're querying.

One rule to remember: you can't style the container itself, only its descendants. That's why every example below styles a child.

So, to dim every cat card except the one snapped into view:

.cat-shelf {
  display: flex;
  gap: 1rem;
  overflow-x: scroll;
  scroll-snap-type: x mandatory;

  > div {
    container-type: scroll-state;
    scroll-snap-align: center;
    flex: 0 0 70%;

    > * {
      transition: opacity 0.5s ease;
    }

    @container not scroll-state(snapped: x) {
      > * { opacity: 0.25; }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
<div class="cat-shelf">
  <div><figure>🐱</figure></div>
  <div><figure>🐈‍⬛</figure></div>
  <div><figure>😺</figure></div>
  <div><figure>🙀</figure></div>
</div>
Enter fullscreen mode Exit fullscreen mode

And the sticky header classic – shadow only once it actually sticks:

header {
  position: sticky;
  top: 0;
  container-type: scroll-state;

  > nav {
    transition: box-shadow 0.3s ease;

    @container scroll-state(stuck: top) {
      box-shadow: 0 2px 8px rgb(0 0 0 / 0.15);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Quick sanity check if you see nothing: run CSS.supports('container-type: scroll-state') in the console. false means your browser, not your code.

Declarative, performant, and you didn't touch a single scroll event. Cards stay visible, headers stay shadowless, nothing breaks.

Bonus points: combine this with section 6 – give your scroll-spy nav position: sticky plus a scroll-state(stuck: top) shadow, and you've got two of this article's features working the same demo.


8. Staggered animations without hardcoding indices – sibling-index() & sibling-count()

Want your list of cats to fade in one after another? The old trick was hardcoding --index: 1, --index: 2... with :nth-child, or worse, looping in JS to set inline styles. Fragile, ugly, breaks the moment the list changes.

The old way (JS):

document.querySelectorAll('.cat').forEach((cat, i) => {
  cat.style.setProperty('--index', i);
});
Enter fullscreen mode Exit fullscreen mode

The new way (pure CSS):

.cat {
  transition: opacity 0.25s ease, translate 0.25s ease;
  /* sibling-index() is 1-based, so subtract 1 to start the first item at 0s */
  transition-delay: calc(0.1s * (sibling-index() - 1));

  @starting-style {
    opacity: 0;
    translate: 1em 0;
  }
}
Enter fullscreen mode Exit fullscreen mode
<div id="shelter">
  <div class="cat">🐱 Mr. Whiskers</div>
  <div class="cat">🐈‍⬛ Pixel</div>
  <div class="cat">😺 Mochi</div>
  <div class="cat">🙀 Baguette</div>
</div>
Enter fullscreen mode Exit fullscreen mode

sibling-index() gives each element its position among siblings – so the stagger math automatically adapts when cats are added or removed. Append a new cat to the container, and it slides in with a delay matching its position.

And sibling-count() gives the total, which unlocks the smarter version: a fixed total stagger duration, no matter how many cats show up. Ten cats or a hundred, the whole cascade always finishes in half a second:

.cat {
  transition-delay: calc(0.5s / sibling-count() * (sibling-index() - 1));
}
Enter fullscreen mode Exit fullscreen mode

9. Dynamic styling from data attributes – if() & typed attr()

Here's the final boss of "I only used JavaScript to change a style." You read a data- attribute, run an if, and toggle a class. CSS can now react to those attribute values directly – no class-toggling round-trip.

First, attr() grew up. It used to only spit out strings into content. Now it can be typed and used on any property:

.cat-card {
  background: attr(data-color type(<color>), gray);
}
Enter fullscreen mode Exit fullscreen mode
<div class="cat-card" data-color="hotpink">Currently: delighted</div>
Enter fullscreen mode Exit fullscreen mode

And here's the part that makes it dynamic: change the attribute – from JS, from your framework, from anywhere – and the style updates instantly. The logic lives in CSS; JS only touches the data:

card.dataset.color = 'mediumseagreen';
Enter fullscreen mode Exit fullscreen mode

(One deliberate exception: attr() can't construct URLs – attributes may hold tokens you don't want leaking into requests, so the <url> type is blocked by design.)

And if() brings ternary-style conditionals straight into your values – no @media block ceremony for a single property:

.layout {
  display: flex;
  flex-direction: column; /* fallback for browsers without if() */
  flex-direction: if(media(orientation: landscape): row; else: column);
}
Enter fullscreen mode Exit fullscreen mode

if() works with media(), supports(), and style() queries. Combine it with typed attr() and you get styling driven directly by your HTML data. The solid, works-today version compares discrete values – note this one reads a keyword from data-mood, not a color:

.cat-card {
  --mood: attr(data-mood type(<custom-ident>), unknown);
  border: 3px solid;
  border-color: if(
    style(--mood: delighted): hotpink;
    style(--mood: grumpy): darkred;
    else: gray
  );
}
Enter fullscreen mode Exit fullscreen mode
<div class="cat-card" data-mood="delighted">Mr. Whiskers is delighted</div>
<div class="cat-card" data-mood="grumpy">Pixel is grumpy</div>
<div class="cat-card" data-mood="sleepy">Mochi is… unaccounted for</div>
Enter fullscreen mode Exit fullscreen mode

Range comparisons are the true bleeding edge. To compare a custom property as a real number or percentage inside style(), you must register it with @property first – otherwise CSS treats it as an opaque string – and even then, range syntax inside if() is the newest piece of this whole article:

@property --rain {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 0%;
}

.cat-card {
  --rain: attr(data-rain-percent type(<percentage>), 0%);
  background: gold;
  background: if(style(--rain > 45%): steelblue; else: gold);
}
Enter fullscreen mode Exit fullscreen mode
<div class="cat-card" data-rain-percent="20%">Rain: 20% – outdoor zoomies</div>
<div class="cat-card" data-rain-percent="80%">Rain: 80% – window judgment</div>
Enter fullscreen mode Exit fullscreen mode

The thing you reached for element.classList.toggle() to do? CSS can now handle it directly. It still can't fetch data, change attributes, or run real logic – but for "style X based on a value that's already in the DOM," the JS middleman is optional now.


So... is JavaScript fired? 🐱

Nah. JavaScript is fine – it's still doing the heavy lifting for actual logic, data, and state. But a huge chunk of the JS we write isn't logic, it's plumbing: opening dialogs, closing them, tracking scroll position, highlighting nav, toggling classes based on data attributes. CSS Wrapped 2025 is basically the platform reclaiming all that plumbing so we can stop writing it.

Less code to ship, less to break, more accessible by default, and it works even if your JS bundle face-plants. That's a win in every bowl.

Go check the full CSS Wrapped 2025 for the other 13 features I didn't cover, poke around MDN, and verify support before you ship.

Did I miss your favorite JS-saver from this year's drop? Tell me in the comments – and which cat you'd adopt.

Top comments (0)