Modern CSS 2019–2026 - New Features and Browser Support

By Javier Nieto
Picture of the author
Published on
Modern CSS features

CSS in 2019 could already do a lot, but it still leaned on JavaScript and utility classes for things that felt like they should be native: reacting to a child element's state, breaking layout out of the document flow, or writing a media query without repeating a color four times. Since then, the CSS Working Group has shipped an unusual number of genuinely new capabilities, and browser vendors have — for the most part — kept pace with each other.

This is a tour of the features that actually changed how CSS gets written, in the order they landed, with the real global support numbers from caniuse.com (checked at the time of writing) and a working code example for each one. At the end, I cover two features that are still drafts today, so you know what's coming next and what to avoid shipping to production just yet.

Quick wins (2019–2021)

A handful of small features quietly removed a lot of boilerplate. They're all north of 94% global support today, so there's no reason not to use them.

FeatureShippedGlobal support
prefers-color-scheme201994.25%
:is() / :where()202096.5%
gap in Flexbox202194.18%
aspect-ratio202194.12%

All four together, in a realistic card component:

:root {
  color-scheme: light dark;
}

.card-list {
  display: flex;
  flex-wrap: wrap;
  gap: 1.5rem; /* gap now works outside of Grid */
}

.card-list img {
  aspect-ratio: 16 / 9; /* no more padding-top hacks */
  object-fit: cover;
}

/* :is() collapses a long selector list into one */
.card-list :is(h2, h3, h4) {
  margin-block: 0.5em;
}

@media (prefers-color-scheme: dark) {
  .card-list {
    background: #111827;
    color: #e5e7eb;
  }
}

Cascade Layers — @layer

Global support: 93.93% (Chrome 99+, Firefox 97+, Safari 15.4+)

Cascade Layers give you a way to control specificity conflicts without relying on selector weight or !important. You declare layer order once, and anything in an earlier layer loses to anything in a later layer — regardless of how specific the selector is.

@layer reset, base, components, utilities;

@layer base {
  a {
    color: blue;
  }
}

@layer components {
  .btn {
    color: white; /* wins over `a` above, even though it's less specific */
  }
}

@layer utilities {
  .text-primary {
    color: var(--accent); /* always wins, by layer order alone */
  }
}

This is the feature that finally makes it reasonable to mix a reset, a design system, and utility classes in the same project without a specificity war.

The :has() selector

Global support: 92.66% (Chrome 105+, Firefox 121+, Safari 15.4+)

:has() is a relational pseudo-class — it lets a selector match based on what's inside an element, something CSS couldn't do at all before. It's often called the "parent selector."

/* Style a form field only when its input is focused */
.form-group:has(input:focus) {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 25%, transparent);
}

/* Remove top margin from a heading if the card has no image */
.card:not(:has(img)) h3 {
  margin-top: 0;
}

Native CSS Nesting

Global support: 90.81% (Chrome 120+, Firefox 117+, Safari 17.2+)

CSS can now nest rules the way Sass always could, without a build step. The & refers to the parent selector, exactly like in Sass.

.card {
  padding: 1.5rem;
  border-radius: 0.75rem;

  & h3 {
    margin-bottom: 0.5rem;
  }

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

  @media (min-width: 768px) {
    padding: 2rem;
  }
}

Container Queries — @container

Global support: 92.6% (Chrome 105+, Firefox 110+, Safari 16+)

Media queries respond to the viewport. Container queries respond to the size of the element's own container, which is what component-driven design actually needed all along — a card can lay itself out differently depending on the column it's dropped into, independent of screen size.

.card-wrapper {
  container-type: inline-size;
  container-name: card;
}

@container card (min-width: 400px) {
  .card {
    display: grid;
    grid-template-columns: 120px 1fr;
    gap: 1rem;
  }
}

Subgrid

Global support: 90.49% (Chrome 117+, Firefox 71+, Safari 16+)

Subgrid lets a grid item's own children align to the parent grid's tracks, instead of creating an isolated grid inside the cell. It's the feature that finally makes it possible to align headings, body copy, and footers across a row of cards, even when their content lengths differ.

.grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 1rem;
}

.card {
  display: grid;
  grid-row: span 3;
  grid-template-rows: subgrid; /* inherits the parent's row tracks */
}

color-mix()

Global support: 91.2% (Chrome 111+, Firefox 113+, Safari 16.4+)

Mixing colors at runtime — tints, shades, alpha blends — used to require Sass functions or a color library. color-mix() does it natively, in any color space.

.button {
  background: var(--accent);
}

.button:hover {
  background: color-mix(in srgb, var(--accent) 85%, black);
}

.button:disabled {
  background: color-mix(in srgb, var(--accent) 40%, transparent);
}

light-dark()

Global support: 86.37% (Chrome 123+, Firefox 120+, Safari 17.5+)

Pairs with color-scheme to pick a light or dark value inline, without duplicating a rule inside a prefers-color-scheme media query.

:root {
  color-scheme: light dark;
}

body {
  background: light-dark(#ffffff, #0f172a);
  color: light-dark(#111827, #e5e7eb);
}

text-wrap: balance

Global support: 89.86% (Chrome 114+, Firefox 121+, Safari 17.5+)

Balances the line lengths of a wrapped heading so it doesn't end with one lonely word on its own line. Browsers used to require a JS library (like Shopify's balance-text) for this.

h1, h2, h3 {
  text-wrap: balance;
}

@starting-style + transition-behavior

Global support: 88.82% (Chrome 117+, Firefox 129+, Safari 17.5+)

Until 2024, CSS couldn't transition an element in — a display: none to display: block change (or a newly-opened <dialog>) always skipped the transition, because there was no "before" state to animate from. @starting-style defines that before state, and transition-behavior: allow-discrete lets discrete properties like display participate in the transition.

.toast {
  opacity: 0;
  transition: opacity 0.3s, display 0.3s allow-discrete;
}

.toast[open] {
  opacity: 1;

  @starting-style {
    opacity: 0;
  }
}

View Transitions API

Global support: 88.46% (Chrome 111+, Firefox 144+, Safari 18+)

Lets you animate between two DOM states — including across full page navigations in a multi-page app — by wrapping the update in document.startViewTransition() and styling the crossfade in CSS.

document.startViewTransition(() => {
  updateTheDOM()
})
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 0.4s;
}

@scope

Global support: 88% (Chrome 118+, Firefox 128+, Safari 17.4+)

Scopes a block of styles to a subtree, with an optional lower boundary, so a component's CSS stops "leaking" into nested components without needing a CSS-in-JS build step or strict BEM discipline.

@scope (.card) to (.card-content) {
  p {
    color: gray; /* only affects <p> inside .card, not inside .card-content */
  }
}

@property

Global support: 92.91% (Chrome 85+, Firefox 128+, Safari 16.4+)

Registers a custom property with a type, an initial value, and whether it inherits — which, among other things, finally makes custom properties animatable. Browsers can't interpolate --angle: 0deg to --angle: 90deg unless they know --angle is an <angle> and not an arbitrary string.

@property --angle {
  syntax: '<angle>';
  inherits: false;
  initial-value: 0deg;
}

.dial {
  background: conic-gradient(from var(--angle), red, orange);
  transition: --angle 0.6s ease;
}

.dial:hover {
  --angle: 270deg;
}

CSS Anchor Positioning

Global support: 81.67% (Chrome 125+, Firefox 147+, Safari 26+)

Anchor Positioning lets you position an element (a tooltip, a popover, a dropdown) relative to another element anywhere in the DOM, without JavaScript measuring bounding boxes. It's the newest feature in this list to cross the majority-support line, so treat it as progressive enhancement for now rather than a hard dependency.

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

.tooltip {
  position: absolute;
  position-anchor: --my-anchor;
  top: anchor(bottom);
  left: anchor(center);
}

Still cooking: what's in draft right now

Not everything new is safe to rely on yet. These two are worth knowing about, but neither has shipped in every major engine.

The if() function

Global support: 66.83% — Chrome/Edge 137+ only. Firefox and Safari have no support yet, and the spec is still a W3C Working Draft.

if() brings inline conditional logic to any CSS property value, based on a media query, a feature query, or a style query (a custom property's current value) — the kind of branching that today requires either duplicated rules or a :has()/attribute-selector workaround.

.card {
  --status: attr(data-status type(<custom-ident>));
  border-color: if(
    style(--status: pending): royalblue;
    style(--status: complete): seagreen;
    else: gray
  );
}

Chrome shipped it as an early preview; don't ship this to production without a fallback declared before it, since if() fails silently (falls back to the cascade) in browsers that don't parse it.

CSS Masonry (a.k.a. Grid Lanes)

Not yet shipped in any stable browser. As of writing, it's testable behind experimental flags — Chrome and Edge 140+ (chrome://flags), Firefox behind a config flag, and Safari behind Feature Flags in Safari Technology Preview. The spec itself is being drafted inside CSS Grid Level 3, and as recently as late 2025 the CSS Working Group was still resolving the syntax between two competing proposals: extending Grid with grid-template-rows: masonry, versus a dedicated display: masonry. The feature was renamed "Grid Lanes" partway through that debate.

/* Experimental syntax — subject to change, behind flags only */
.gallery {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: masonry;
  gap: 1rem;
}

Worth prototyping behind a flag if you're curious, but treat any masonry layout in production the way we have for years — with columns or a JS library — until this actually ships.

Where that leaves things

Most of what's on this list clears 88% global support, which in practice means "safe to ship without a fallback" for the vast majority of products. The exceptions — Anchor Positioning, if(), and Masonry — are the ones to watch over the next year or two; they're the clearest signal of where the spec is heading next: less JavaScript for things that are fundamentally about layout and style, not application logic.