Native CSS Nesting in WordPress Themes Without a Preprocessor

Native CSS nesting — the ability to write selector rules inside other selector rules without a preprocessor — is now supported in all evergreen browsers: Chrome 120+, Firefox 117+, Safari 16.5+, Edge 120+. For WordPress theme development this means BEM-structured block styles, responsive component variations, and state-based selectors can be expressed with visual hierarchy in plain style.css without a Sass or LESS compilation step. The core of CSS nesting is the nesting selector & which represents the parent rule’s full selector: inside .wp-block-card { }, writing & .wp-block-card__title { } is equivalent to .wp-block-card .wp-block-card__title { } (descendant), while &:hover { } is .wp-block-card:hover { } (compound, same element), and &.is-style-wide { } is .wp-block-card.is-style-wide { } (also same element, no space). A critical spec evolution: Chrome 112–119 required all nested rules to begin with & or be an @-rule — bare element type selectors like p { } inside a nest caused a syntax error. Chrome 120 (December 2023) removed this restriction, implementing the final W3C specification that allows bare element selectors inside nests. Firefox and Safari support bare nested element selectors in their current stable releases as well. @media, @supports, @container, and @layer at-rules can be nested directly inside selector rules, enabling the highly readable pattern of writing a component’s responsive variations alongside its base styles in a single nested block. The cascade layer @layer (Chrome 99+, Firefox 97+, Safari 15.4+) pairs naturally with nesting — a block theme can group its default block styles in a defaults layer while child-theme overrides declared outside any layer win the cascade automatically without needing !important. WordPress’s theme.json-generated CSS does not use nesting, but any stylesheet registered via wp_enqueue_style() or wp_enqueue_block_style() is parsed natively by the browser and can freely use CSS nesting. The CSS Container Queries post covered @container for responsive layouts; native CSS nesting makes those container queries writable alongside the component’s base styles in a single indented block.

Problem: A WordPress child theme’s stylesheet is authored in style.scss and compiled with sass --watch — the Sass build step is the only reason the pipeline exists. Non-developer contributors cannot make CSS changes without the build setup, and the compiled style.css frequently has merge conflicts when two developers edit style.scss on separate branches.

Solution: Migrate style.scss to plain style.css using native CSS nesting syntax — replace Sass $variable references with CSS custom properties and replace Sass &__element shorthand with the native & .block__element syntax. The result is a single source-of-truth file the browser parses directly with no build step.

/* ── Before: Sass nesting ─────────────────────────────────────────────────── */

$color-primary: #1a56db;
$color-text:    #111827;

.wp-block-card {
    border: 1px solid #e5e7eb;
    border-radius: 8px;
    overflow: hidden;

    &__image { width: 100%; aspect-ratio: 16/9; object-fit: cover; }
    &__body  { padding: 1.25rem; }
    &__title {
        font-size: 1.125rem;
        color: $color-text;
        a { color: $color-primary; text-decoration: none; }
    }
    &:hover { box-shadow: 0 4px 12px rgba(0,0,0,.1); }

    @media (min-width: 768px) {
        display: flex;
        &__image { width: 40%; aspect-ratio: auto; }
    }
}

/* ── After: Native CSS nesting (Chrome 120+, Firefox 117+, Safari 16.5+) ──── */

:root {
    --color-primary: #1a56db;
    --color-text:    #111827;
}

.wp-block-card {
    border: 1px solid #e5e7eb;
    border-radius: 8px;
    overflow: hidden;

    /* Descendant: full class name required (no Sass & shorthand) */
    & .wp-block-card__image { width: 100%; aspect-ratio: 16/9; object-fit: cover; }
    & .wp-block-card__body  { padding: 1.25rem; }
    & .wp-block-card__title {
        font-size: 1.125rem;
        color: var(--color-text);
        /* Bare element selector — valid in Chrome 120+ / Firefox 117+ */
        a { color: var(--color-primary); text-decoration: none; }
    }

    /* Compound selector (same element): & with no space */
    &:hover { box-shadow: 0 4px 12px rgba(0,0,0,.1); }

    /* @media nested inside selector — scoped to .wp-block-card only */
    @media (min-width: 768px) {
        display: flex;
        & .wp-block-card__image { width: 40%; aspect-ratio: auto; }
    }
}

/* ── @layer + CSS nesting for cascade control in block themes ──────────────── */

/* Declare layer order — unlayered rules win over all named layers */
@layer reset, defaults, theme, utilities;

@layer defaults {
    .wp-block-button__link {
        background-color: var(--wp--preset--color--primary);
        color: #fff;
        border-radius: var(--wp--custom--border-radius, 4px);

        &:hover,
        &:focus-visible {
            background-color: color-mix(in srgb, var(--wp--preset--color--primary) 80%, black);
        }
    }
}

@layer theme {
    .wp-block-cover {
        min-height: 60svh;

        @supports (min-height: 60dvh) {
            min-height: 60dvh;
        }

        & .wp-block-cover__inner-container {
            max-width: var(--wp--style--global--content-size);
            margin-inline: auto;
        }
    }
}

/* Unlayered utility — wins over @layer defaults and @layer theme automatically */
.sr-only {
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    height: 1px;
    overflow: hidden;
    position: absolute;
    white-space: nowrap;
    width: 1px;
}

NOTE: There is a compatibility nuance between & p { } (explicit nesting selector) and bare p { } inside a nest — both produce identical results in Chrome 120+, Firefox 117+, and Safari 16.5+, but bare element selectors fail silently (the entire nested rule is ignored) in Chrome 112–119. If your site analytics show significant traffic from Chrome below 120, use the explicit & element { } syntax for nested element type selectors to ensure backward compatibility. CSS specificity is calculated from the resolved selector, not the nesting structure — .card { &:hover { color: red; } } resolves to .card:hover { color: red; } with specificity (0,2,0), exactly the same as writing it flat. Nesting does not add extra specificity.