Native CSS Nesting Is Here: Ditch the Preprocessor

Native CSS nesting works in every current browser. The syntax, the `&` rules, and what still needs a preprocessor.

csstutorialbeginner

Nesting selectors inside other selectors was the main reason I put up with Sass for years. Browsers handle it natively now, and there's no build step involved.

Basic Syntax

/* Native CSS Nesting */
.card {
    background: white;
    border-radius: 8px;
    padding: 1.5rem;
 
    .card-title {
        font-size: 1.25rem;
        font-weight: 600;
    }
 
    .card-body {
        color: oklch(0.4 0.02 260);
        margin-top: 0.75rem;
    }
 
    &:hover {
        box-shadow: 0 4px 12px oklch(0 0 0 / 0.1);
    }
}

Which is the same as writing this:

.card { background: white; border-radius: 8px; padding: 1.5rem; }
.card .card-title { font-size: 1.25rem; font-weight: 600; }
.card .card-body { color: oklch(0.4 0.02 260); margin-top: 0.75rem; }
.card:hover { box-shadow: 0 4px 12px oklch(0 0 0 / 0.1); }

The & Selector

& means the parent selector. You need it when you're appending to the parent rather than adding a descendant:

.button {
    /* Modifier classes — & is required here */
    &.button--primary { background: oklch(0.55 0.22 260); }
    &.button--danger  { background: oklch(0.55 0.22 25); }
 
    /* Pseudo-classes — & is required */
    &:hover { opacity: 0.85; }
    &:disabled { opacity: 0.4; cursor: not-allowed; }
 
    /* Pseudo-elements — & is required */
    &::before { content: ""; display: block; }
 
    /* Descendant — & is optional (but explicit) */
    & .icon { width: 1em; height: 1em; }
    .icon { width: 1em; height: 1em; } /* Same result */
}

Bare .icon and & .icon compile to the same thing. I still write the second one when I mean a descendant, because it survives being moved out of the block during a refactor.

Media Queries Inside Selectors

@media, @supports and other at-rules nest too:

.sidebar {
    width: 280px;
    flex-shrink: 0;
 
    @media (max-width: 768px) {
        width: 100%;
        flex-direction: column;
    }
 
    @supports (display: grid) {
        display: grid;
        grid-template-rows: auto 1fr auto;
    }
}

The responsive overrides stay next to the rule they change. In flat CSS the mobile version of a component ends up 400 lines below it.

Nesting and Specificity

A nested selector has the same specificity as the flat version. Nesting adds no weight:

.card .title { color: red; }
/* Specificity: 0-2-0 */
 
/* Equivalent nested version */
.card {
    .title { color: red; }
}
/* Specificity: 0-2-0 — same */

Good news when porting Sass, less so if you hoped nesting would settle a specificity fight. It won't.

Key Difference From Sass: Interpolation

Native nesting has no equivalent of Sass selector interpolation:

/* Sass — works */
$prefix: "btn";
.#{$prefix}-primary { color: white; }
/* Native CSS — no interpolation */
/* This is not possible natively */

If selectors have to be generated from a variable, a preprocessor or JavaScript is still the way.

Mixing With CSS Custom Properties

Custom properties and nesting go together well for component theming:

.button {
    --bg: oklch(0.55 0.22 260);
    --fg: white;
 
    background: var(--bg);
    color: var(--fg);
    padding: 0.5rem 1rem;
    border-radius: 6px;
 
    &:hover {
        --bg: oklch(0.45 0.22 260); /* Darker on hover */
    }
 
    &.danger {
        --bg: oklch(0.55 0.22 25);
    }
}

Overriding --bg on hover rather than background keeps the hover rule from having to know the base colour. Small thing, but it's the reason I started structuring themes this way.

Migrating From Sass

Most Sass nesting converts one-to-one. The differences worth knowing:

FeatureSassNative CSS
Basic nesting✅✅
& concatenation✅✅
@media nesting✅✅
Variables$var--var
Interpolation#{$var}❌ Not supported
Mixins@mixin❌ Not supported
Functionsdarken() etc.oklch(), color-mix()
Loops@each, @for❌ Not supported

If nesting and variables were the only Sass features a project used, native CSS covers it now.

Browser Support

All current browsers support it:

  • Chrome 120+ (December 2023)
  • Firefox 117+ (August 2023)
  • Safari 17.2+ (December 2023)
  • Edge 120+ (December 2023)

So the preprocessor can go, provided nothing else in the build depends on it. Fewer tools to keep current, and the CSS does the same job.