The new CSS Nesting module… I mean spec

A couple of weeks ago, the CSS Working Group published the first Editor’s Draft of the CSS Nesting Module. And there was much rejoicing, for it was a long overdue addition to the CSS standard. Yes, it’s an Editor’s Draft, meaning it — and by extension everything I discuss in this article — is subject to major changes through its development, but the fact that it’s a W3C draft at all is in itself worth celebrating to many.

As I mentioned in my last entry, I’ve been in a very rough place over the last couple of weeks (actually close to a month now), but I’m happy to report that I’m doing much better this week so I finally had a bit of time to peruse the new spec. In doing so, I thought I’d try my hand at providing an overview of its features along with sharing my personal thoughts on what I see.

No stylesheet preprocessor experience required — this is written from the perspective of someone who has never, ever used one in 13 years of writing CSS (outside of answering questions on Stack Overflow, so I know at the very least a thing or two about them).

Table of contents

  1. Introduction
  2. The nesting selector &
  3. Nesting style rules
    1. Direct nesting
    2. The @nest at-rule
  4. How I might use this feature
  5. Cascading
  6. Limitations
  7. A note on preprocessors, browsers, and compatibility

Introduction

If you’re familiar at all with Sass/SCSS, LESS, or most any other stylesheet preprocessor language, you probably know one of their most distinctive common features — nesting style rules, and the & selector (which preprocessors call the “parent selector” or “parent combinator” whereas css-nesting calls the “nesting selector”, a much less confusing name for it IMO):

article {
    line-height: 1.5;
    color: #556077;
    background-color: #e0e0e0;
    margin: 1rem;
    border: medium solid;
    padding: 0.5rem;

    li {
        margin: 0.5em 0;
    }

    > p, > ol, > ul {
        margin: 1em 0;

        &:first-of-type { margin-top: 0; }
        &:last-of-type { margin-bottom: 0; }
    }

    :root.archive & {
        font-size: 0.9rem;
    }
}

If you don’t, here’s the result of transpiling the above snippet to standard CSS:

article {
    line-height: 1.5;
    color: #556077;
    background-color: #e0e0e0;
    margin: 1rem;
    border: medium solid;
    padding: 0.5rem;
}

article li {
    margin: 0.5em 0;
}

article > p, article > ol, article > ul {
    margin: 1em 0;
}

article > p:first-of-type, article > ol:first-of-type, article > ul:first-of-type {
    margin-top: 0;
}

article > p:last-of-type, article > ol:last-of-type, article > ul:last-of-type {
    margin-bottom: 0;
}

:root.archive article {
    font-size: 0.9rem;
}

In a nutshell, nesting style rules saves the author having to write duplicated portions of contextual selectors, and introduces a visual hierarchy for said context.

But, you ask, since this has been baked into popular preprocessors for years, why introduce it to CSS now? Well, there are a couple of benefits I can think of:

There’s a significant caveat, though, which I’ll get to in a bit. For now, let’s look at how exactly this new spec goes about standardizing the feature. There are several syntactic differences which mean that the above SCSS snippet is not directly portable to the new standard without a few small changes.

The nesting selector &

As mentioned above, you may know this better as the “parent selector” (or “parent combinator”), because it represents the selector of the parent rule (i.e. that which the rule with the & selector is nested in).

But we all know how confusing that term is, so css-nesting does away with it entirely and calls it what it is: the nesting selector. Here’s an example, straight from section 2 of the spec:

The nesting selector can be desugared by replacing it with the parent style rule’s selector, wrapped in a :matches() selector. For example,

a, b {
  & c { color: blue; }
}

is equivalent to

:matches(a, b) c { color: blue; }

:matches(), new to Selectors level 4, was actually recently renamed to :is() after several years of hanging around having only been implemented in Safari since version 9. For the uninitiated, the selector

:matches(a, b) c /* Or :is(a, b) c */

is equivalent to

a c, b c

The spec adds:

When used in any other context, it represents nothing. (That is, it’s valid, but matches no elements.)

I wasn’t quite sure what “represents nothing” and “matches no elements” actually meant, so I asked the editor to clarify. Here’s what they said:

That sounds like it would keep selectors usable when un-nesting a rule. Pretty neat, assuming the specified behavior doesn’t change.

As an aside, I was pleased to see this note on grammar:

Note: This is required to allow direct nesting. Also, the “type selectors must come first” has no intrinsic reason behind it; it exists because we need to be able to tell simple selectors apart unambiguously when they’re directly appended together in a compound selector, and it’s not clear from .foodiv that it should mean the same as div.foo. An ampersand is unambiguously separable from an ident, tho, so there is no problem with it preceding a type selector, like &div.

Only because it solidifies a statement I made on Stack Overflow several years ago on why only one type selector may appear in a compound selector and it must come first:

Remember that a type selector consists of simply an identifier, e.g. h1. This is unlike other simple selectors which have their own distinguishing symbols in the grammar, such as an ID (#), a class (.), a pseudo-class (:), or an attribute selector ([]). You would not be able to have multiple consecutive type selectors without a way to parse them separately.

Right, now for the main course: the nesting of style rules itself!

Nesting style rules

You may have noticed the use of & in a case where it’s typically not needed when using a preprocessor. The key difference between standard nesting and what we’re used to is that the nesting selector must appear in all the complex selectors of a nested style rule. This is to accommodate a limitation in the current, tried and true implementation of selector parsers, which the spec details in the introduction of section 3.

There are two ways the & selector can be incorporated into a nested style rule. Each is covered in its own subsection of section 3. I was delighted reading through the section as a whole as, armed with some knowledge of CSS itself and the standardization process, I was able to glean some insight into how a popular preprocessor feature gets integrated into the CSS standard and the considerations that need to be made in its development. Writing web standards is not easy; there are many, many factors to account for that I couldn’t list all here.

So, before I digress, let’s look at the two ways the & selector can be used.

Direct nesting

The first is called direct nesting, in which a style rule is, indeed, directly embedded into the guts of another style rule. Direct nesting can be done provided all the complex selectors of that rule must begin with &. We call such complex selectors nest-prefixed selectors.

Let’s look at our SCSS example from the introduction again, now with annotations:

/* Top level */
article {
    line-height: 1.5;
    color: #556077;
    background-color: #e0e0e0;
    margin: 1rem;
    border: medium solid;
    padding: 0.5rem;

    /* Needs fix */
    li {
        margin: 0.5em 0;
    }

    /* Needs fix */
    > p, > ol, > ul {
        margin: 1em 0;

        /* OK */
        &:first-of-type { margin-top: 0; }
        &:last-of-type { margin-bottom: 0; }
    }

    /* Needs fix */
    :root.archive & {
        font-size: 0.9rem;
    }
}

We can see that, among the nested rules, only the :first-of-type and :last-of-type pseudo-classes begin with &. It doesn’t appear at all with the li compound selector (which transpiles to a contextual selector with an implicit descendant combinator, article li) nor any of the (what Selectors 4 calls) relative selectors > p, > ol and > ul (which transpile into contextual selectors with those combinators). It appears after the :root.archive selector, which means that rule still can’t be directly nested — we’ll get to that shortly.

For now, we can make the first couple of nested rules work by prepending the nesting selector to each complex selector that’s missing one:

/* Top level */
article {
    line-height: 1.5;
    color: #556077;
    background-color: #e0e0e0;
    margin: 1rem;
    border: medium solid;
    padding: 0.5rem;

    /* Fixed! */
    & li {
        margin: 0.5em 0;
    }

    /* Fixed! */
    & > p, & > ol, & > ul {
        margin: 1em 0;

        /* OK */
        &:first-of-type { margin-top: 0; }
        &:last-of-type { margin-bottom: 0; }
    }

    /* Needs fix */
    :root.archive & {
        font-size: 0.9rem;
    }
}

It doesn’t matter if the & appears before a combinator or directly before another simple selector; as long as it appears at the very beginning of each complex selector, the rule can be directly nested.

How do we implement the :root.archive & rule next?

The @nest at-rule

Along with direct nesting, the @nest at-rule is introduced so we can explicitly nest any rule that contains a nesting selector anywhere in each of its complex selectors. (Only one @nest at-keyword is needed per rule and list of selectors.)

We can now implement the :root.archive & rule by adding the @nest at-keyword to it:

/* Top level */
article {
    line-height: 1.5;
    color: #556077;
    background-color: #e0e0e0;
    margin: 1rem;
    border: medium solid;
    padding: 0.5rem;

    /* Fixed! */
    & li {
        margin: 0.5em 0;
    }

    /* Fixed! */
    & > p, & > ol, & > ul {
        margin: 1em 0;

        /* OK */
        &:first-of-type { margin-top: 0; }
        &:last-of-type { margin-bottom: 0; }
    }

    /* Fixed! */
    @nest :root.archive & {
        font-size: 0.9rem;
    }
}

(Prism, the syntax highlighting library that I use, isn’t set up to handle selectors in at-rules yet, which is why the selector isn’t being highlighted correctly.)

It’s not limited to selectors that aren’t nest-prefixed, either. We could go all the way and add @nest to every single nested rule, perhaps for the sake of clarity:

/* Top level */
article {
    line-height: 1.5;
    color: #556077;
    background-color: #e0e0e0;
    margin: 1rem;
    border: medium solid;
    padding: 0.5rem;

    /* Fixed! */
    @nest & li {
        margin: 0.5em 0;
    }

    /* Fixed! */
    @nest & > p, & > ol, & > ul {
        margin: 1em 0;

        /* OK... but fixed anyhow! */
        @nest &:first-of-type { margin-top: 0; }
        @nest &:last-of-type { margin-bottom: 0; }
    }

    /* Fixed! */
    @nest :root.archive & {
        font-size: 0.9rem;
    }
}

Personally, I think rules with nest-prefixed selectors don’t need the @nest at-keyword. The way I see it, the leading & serves as a “shorthand” for the at-keyword. The spec doesn’t explicitly call it that, but for all intents and purposes, that’s what it is to me.

Anyway, the stylesheet now conforms to the new CSS Nesting spec! Compare it to the traditional, non-nested CSS shown in the introduction:

article {
    line-height: 1.5;
    color: #556077;
    background-color: #e0e0e0;
    margin: 1rem;
    border: medium solid;
    padding: 0.5rem;
}

article li {
    margin: 0.5em 0;
}

article > p, article > ol, article > ul {
    margin: 1em 0;
}

article > p:first-of-type, article > ol:first-of-type, article > ul:first-of-type {
    margin-top: 0;
}

article > p:last-of-type, article > ol:last-of-type, article > ul:last-of-type {
    margin-bottom: 0;
}

:root.archive article {
    font-size: 0.9rem;
}

It looks more sophisticated, there’s lots more indentation (which might be an eyesore for some, maybe, entirely subjective)… but there’s also less duplication in the selectors (which may turn out to be more, or less, readable depending on the author and what they’re used to). Oh and there’s lots more &s — enough to rival an HTML document, I’d bet!

Last but not least, all the nested CSS you see can still be minified. The indentation is, as with any other indentation, entirely optional and there for readability. It doesn’t have any impact on how @nest at-rules and rules/selectors containing the & selector are parsed.

How I might use this feature

I have to confess, for all the DRYness that nesting style rules brings to the table, one thing I dislike about it (and to a lesser extent, nesting @media rules) is that its overuse can cause selector fragmentation, making it harder to search, read and maintain stylesheets when rules get large and convoluted.

Instead, I prefer having all the context in a selector. Yes, it means more duplication, particularly for selectors with many compound selectors (a notorious habit of mine), but it also means I can look at a selector and know exactly what elements it represents, as well as search for parts of selectors and find complete selectors, again with all the context right there in each selector.

So, what I think I’ll do (and what you can start practicing with existing preprocessor stylesheets today if you’re so inclined!) is use nesting only with smaller CSS rules when I believe doing so improves, not hinders, readability, because all the context is there on screen at once, and avoid fragmenting my selectors unnecessarily to aid in searches.

Consider the following non-nested CSS snippet, taken straight from my own stylesheet:

button, input, select, textarea {
    /* Truncated for brevity */
    transition-property: color, background-color;
    transition-duration: 0.15s;
    transition-timing-function: linear;
}

button:hover, button:focus, input:hover, input:focus, select:hover, select:focus, textarea:hover, textarea:focus {
    transition-duration: 0.03s;
}

This would benefit greatly from nesting:

button, input, select, textarea {
    /* Truncated for brevity */
    transition-property: color, background-color;
    transition-duration: 0.15s;
    transition-timing-function: linear;

    &:hover, &:focus {
        transition-duration: 0.03s;
    }
}

The line count remains the same (or, if you added line breaks to avoid that horizontal scrollbar, the line count is reduced!), a lot of duplication is eliminated (not only the type selectors, but also the :hover and :focus pseudo-classes), and the rules are small enough that you can still see the context all at once and understand immediately that the declarations apply to button, input, select and textarea elements.

But consider something much larger, with many more unique rules. Here are just the CSS rules for my home page that don’t have media queries (all declarations truncated as the focus is on selectors, but for reference this is 67 lines long including declarations and blank lines):

html.home > body {}

main.home {}

main.home > header {}

main.home > header > h1 {}

main.home > header > h1 > img {}

main.home > header > p {}

main.home > section {}

main.home > section > h2, main.home > section > p, main.home > section > .tilegroup {}

main.home > section > h2 {}

main.home > section > h2 + p {}

main.home > section > .tilegroup {}

Let’s group all of these rules according to the .home class selector, as a first step (and remove the html and main type selectors where appropriate while we’re at it because, realistically, they’re not needed, and keeping them means every nested rule requires @nest to work):

.home {
    & > body {}

    @nest main& {}

    & > header {}

    & > header > h1 {}

    & > header > h1 > img {}

    & > header > p {}

    & > section {}

    & > section > h2, & > section > p, & > section > .tilegroup {}

    & > section > h2 {}

    & > section > h2 + p {}

    & > section > .tilegroup {}
}

OK, so we just increased the line count by 2. But not only that, we’ve all but lost the context for all of these selectors — what do all the &s represent? We now have to refer to the top level to discover, or remind ourselves, that we’re working in the context of the .home class selector, i.e. we’re working with CSS that pertains to my home page. (There’s also the fact that I know intimately that these rules are unique to my home page anyway, but that’s completely irrelevant and useless in situations where multiple developers in a team are working on the same stylesheet.)

Before we scramble for the undo button, let’s break these rules down some more:

.home {
    & > body {}

    @nest main& {
        /* Declarations */

        & > header {
            /* Declarations */

            & > h1 {
                /* Declarations */

                & > img {}
            }

            & > p {}
        }

        & > section {
            /* Declarations */

            & > h2, & > p, & > .tilegroup {}

            & > h2 {
                /* Declarations */

                & + p {}
            }

            & > .tilegroup {}
        }
    }
}

Now we have a bunch of extremely fragmented selectors that are practically impossible to search for (unless you know exactly how to drill your sequential searches down them). Not to mention, with the declarations, this is now over 70 lines long. Most experts would advise you to avoid this even in your preprocessor stylesheets, and they really do have a point.

So let’s start from the beginning, and turn our focus to selectors that start with main.home. Notice that there are two distinct groups under main.home: main.home > header, and main.home > section. What if we

  1. grouped these under the top-level main.home, then
  2. grouped the next level of rules under them, and no more?
html.home > body {}

main.home {
    /* Declarations */

    & > header {
        /* Declarations */

        & > h1 {}

        & > h1 > img {}

        & > p {}
    }

    & > section {
        /* Declarations */
        
        & > h2, & > p, & > .tilegroup {}

        & > h2 {}

        & > h2 + p {}

        & > .tilegroup {}
    }
}

We now have a proper main.home selector rather than the awkward main&, which we can use as a starting point for searches (though it’s only marginally better as one than .home if we’re honest). However I still don’t like that it’s split from the header and section elements, so let’s un-nest those:

html.home > body {}

main.home {}

main.home > header {
    /* Declarations */

    & > h1 {}

    & > h1 > img {}

    & > p {}
}

main.home > section {
    /* Declarations */
    
    & > h2, & > p, & > .tilegroup {}

    & > h2 {}

    & > h2 + p {}

    & > .tilegroup {}
}

This results in main.header‘s own declarations being separated from the rest, but I consider that a minor trade-off compared to being able to find main.home > header and main.home > section quickly, either by searching, or by a quick glance as I’m reviewing my stylesheet.

This is a balance I’m happy with. Sure there’s still a fair number of &s in there, but the rules are divided into far more manageable chunks overall, and I never find myself getting lost. This, to me, is CSS nesting done right. Maybe you’ll benefit from it too!

Of course, perhaps the single most effective optimization I could make is to move these CSS rules to a separate stylesheet that loads only with my home page, since they never get used anywhere else. In fact, that’s exactly what I’ve done as I’m working on upgrading my home page — I just haven’t had the chance to ship it yet as it’s nowhere near done.

Cascading

All nested rules and all declarations (nested and otherwise) follow the usual rules of cascade resolution. Rules with more specific selectors take precedence, the most specific declarations that come last take precedence, etc.

Depending on your declarations, you can take advantage of this by moving nested rules with more specific selectors to the top of the rule they’re nested in. The form control example I showed you could be rewritten like so:

button, input, select, textarea {
    &:hover, &:focus {
        transition-duration: 0.03s;
    }

    /* Truncated for brevity */
    transition-property: color, background-color;
    transition-duration: 0.15s;
    transition-timing-function: linear;
}

The pseudo-class specificity of the nested rule ensures that the transition-duration: 0.03s declaration will continue to apply to elements while they are in those states, even though the transition-duration: 0.15s declaration of the parent rule now appears later.

Limitations

One limitation that jumps out to me is the fact that there is no mention of CSS conditional group rules such as @media and @supports, so it’s not clear if style rules and conditional group rules can be nested within one another. (css-conditional-3 allows conditional group rules to be directly nested within one another only.) Preprocessors on the other hand do allow you this freedom to mix and match. Consider another snippet from my stylesheet:

img.left, figure.left {
    float: left;
    margin: 0 1em 1.5em 0;
}

img.right, figure.right {
    float: right;
    margin: 0 0 1.5em 1em;
}

@media only screen and (max-width: 400px) {
    img.left, figure.left, img.right, figure.right {
        float: none;
        margin: 1.5em auto;
    }
}

In SCSS, this could be rewritten like so:

img, figure {
    &.left {
        float: left;
        margin: 0 1em 1.5em 0;
    }

    &.right {
        float: right;
        margin: 0 0 1.5em 1em;
    }

    @media only screen and (max-width: 400px) {
        &.left, &.right {
            float: none;
            margin: 1.5em auto;
        }
    }
}

But the new spec doesn’t make it clear whether this is possible. There is zero mention of the css-conditional-3 spec nor any of its features. I expect this to be addressed in a revision to the draft, since I’m sure it’s gonna be brought up by many others. Given that these are all at-rules, it shouldn’t be too hard to extend the grammar to accommodate them as is being done for the @nest at-rule.

The other limitation is not so much a limitation of the spec, as it is a limitation of the current state of the web.

A note on preprocessors, browsers, and compatibility

Ah, yes, the dreaded word: compatibility. This is the caveat I mentioned. Why is compatibility a big deal here?

Now, this is all speculation on my part since I don’t know the development of each preprocessor language, but as I understand it, all preprocessors will continue flattening nested rules to non-nested CSS for a while, since the new spec probably isn’t going to be implemented natively in browsers anytime soon. This has several implications:

  1. Non-nested CSS will remain compatible with all browser versions, bloated as they are. (One trap that I see many SCSS/LESS authors falling into is getting too comfortable with nesting rules and producing very, very bloated CSS.)

  2. Browser versions shipping nested CSS rules won’t get to actually use the feature, since it’s not in use.

  3. Authors who deliberately write CSS conforming to the new spec (i.e. independently of a preprocessor) will enjoy support by browser versions that implement it, and lose support by older versions unless they serve an additional, different version of their stylesheet with no nested rules.

Eventually, when the spec is widely implemented, I foresee preprocessors offering an option to leave nested rules (or rules with the @nest at-keyword) untouched, keeping the output CSS lean and allowing browsers to take advantage of the new syntax and smaller file size. This won’t completely address point #3, though. Authors will still want to enable flattening of nested rules if they want to maintain compatibility with by-then-older browsers. I honestly don’t find it justifiable or sustainable to maintain two separate variations of the same stylesheet that differ solely in the nesting of rules.

And it will be quite a while before the web reaches a point where nested CSS is widely implemented and we can even talk about “older browsers” in the sense of not supporting nested CSS. But hey, it’s 2019 and @supports is considered widely implemented now, so there’s a success story for us.

Having said all this, I’m excited for the future and will be following the development of css-nesting, well, fairly closely. Still not nearly as closely as selectors-4 or mediaqueries-5

If you have any thoughts to share about the new CSS Nesting spec, or what I’ve written about it, you’re more than welcome to leave a comment. But you’ll want to direct any actionable feedback about the spec to those who actually work on it — you can do that by posting an issue on GitHub, prefixing the title of your issue with “[css-nesting]”. Thanks for reading!

Comments are closed.