← All writing
Craft · · 10 min

Native nesting and the ampersand

On the ampersand that isn't text, the three ways native nesting fails without telling you, and the discipline problem it was never going to fix.

CSS SCSS

A product card came back as a column of unstyled text with the padding still on it.

I’d spent that morning moving the component off SCSS. Native nesting has been in all 3 engines since last summer, the file was about 60 lines, and the whole conversion was renaming it and pointing the import at the new name. I changed nothing else, and not changing anything else was the entire point.

Nothing was crossed out in DevTools either, which is the part that had me sitting there for a minute. A rule that loses a specificity fight gets a line through the declaration that lost. A rule the parser refused gets nothing at all, because it isn’t a rule. So there’s no line in the pane to inspect and no complaint in the console. That’s a different silence from the one I spent April on, where the rule was perfectly valid and simply being read in the wrong room. The declarations belonging to .card itself kept applying, so what I was looking at was a component half assembled rather than one that had gone missing.

card.css
.card {
  /* these applied */
  border-radius: 4px;
  padding: 1rem;

  /* this was never a rule at all */
  &__title {
    font-size: 1.25rem;
  }
}
CSS

I’ve typed that shape for years and it has never once not worked. The two things are both called nesting and the syntax is ostensibly identical, which is how you get a file that looks correct and does nothing.

It was concatenation all along

In SCSS none of this is CSS yet. The compiler swaps & for the parent selector as text, so &__title becomes the literal characters .card__title and a flat class selector is what lands in the output file. It’s a text transform on the way to a stylesheet. It’s why BEM and the preprocessor turned up in everybody’s project at the same time. Deeply 2014-coded.

Natively there’s nothing doing the swapping. & is a real selector standing for the enclosing rule’s subject, resolved by the browser as it parses, the same way :hover is a real selector instead of six characters waiting to be pasted somewhere. So &__title isn’t a name being assembled. It’s & with __title stuck on the end of it, and a compound selector can’t take an element name in that position, so the selector is invalid and the rule goes in the trash. It isn’t a support question, it’s a grammar one.

Which takes the most common use of nesting in the SCSS world, building class names out of fragments, permanently off the table. Not “not yet.” There’s no string sitting there to concatenate to. So there’s nothing anybody can add later that would make it work.

I did warn me

The embarrassing part is that I’d already made this argument myself.

I wrote a post about BEM in 2015 with a paragraph in it saying I’d generated class names that way for about a month and then stopped, because .card__title then appears nowhere in the codebase, so somebody who inspects an element, copies the class and searches for it finds nothing. I called the searchability most of what the convention was buying.

Then I joined a codebase in 2022 that was &__ from top to bottom. Not gonna lie, I have been writing it perfectly happily ever since, and I did not notice I’d changed my mind until a browser declined to accept it 🙃

So native nesting didn’t take anything away from me. It took away a thing I’d argued against in public and then gone back to without ever saying so. I’m not above that.

Guilt by association

The second one costs an afternoon instead of a morning.

& behaves like :is(), which means it doesn’t carry the specificity of the selector you’re reading. It carries the specificity of the most specific selector in the enclosing list.

specificity.css
.button, #cta {
  /* weighs what #cta weighs, even on the element that matched .button */
  & .icon {
    fill: currentColor;
  }
}
CSS

In SCSS that comes out as .button .icon, #cta .icon, and the first of the two has ordinary class specificity. Natively it’s one rule carrying an id’s weight on everything it matches.

Nobody hits this until there’s an id somewhere in a selector list, at which point it’s genuinely baffling, because the rule in front of you looks like two classes and it’s beating things that two classes cannot beat. Ids in selector lists are a problem you already had. So I’d treat this as a decent way of finding out where they are. When cascade layers landed I wrote that specificity is a bad stand-in for how deliberate you were being, and this is that same complaint arriving from a new direction.

Last call for declarations

The third one is a browser thing instead of a language thing, it’s temporary, and it fails in exactly the same silent way, so it’s the one I’d warn a team about first.

Declarations that come after a nested rule, at the same level, are currently thrown away.

button.css
.button {
  padding: 0.5rem 1rem;

  &:hover {
    background-color: var(--accent);
  }

  /* dropped in today's browsers */
  border-radius: 4px;
}
CSS

Declarations inside a nested media query are fine, since those get treated as a rule of their own. It’s specifically the ones following a nested rule in the same block that go missing. There’s a change in the works to make them behave the way everybody already assumes they do.

Until that’s everywhere, the rule is to put every declaration at the top of the block and every nested rule underneath, which is how you’d want to read the thing anyway, and which nobody is going to remember at 5 pm.

The bottom of the file is gone

Both of the uses that were always the good part of nesting. They cost nothing now.

States next to the thing they modify. &:hover, &:focus-visible, &[aria-expanded="true"], &:disabled, sitting inside the rule they belong to instead of 40 lines further down, where somebody eventually puts a heading comment between them and separates them for good.

And media queries inside the component, which is the one I’d have paid money for.

nesting.css
.card {
  display: block;
  padding: 1rem;

  &:hover {
    border-color: var(--accent);
  }

  @media (min-width: 40em) {
    display: grid;
    grid-template-columns: 8rem 1fr;
  }
}
CSS

The responsive behavior of a component now lives in the component, instead of in a breakpoint block at the bottom of the file with 20 unrelated selectors in it. Every stylesheet I have ever written has had that block in it and it was the worst part of all of them.

Worth knowing that the first implementations wanted a symbol in front of a nested element selector and the current ones don’t, which changed over the winter, so a good deal of what you’ll find written about this is describing a browser nobody is running.

Congratulations, it’s a descendant selector

Now the part where I’m a stick in the mud.

That old post had three complaints about nesting selectors five deep in SCSS. The output is a long descendant selector with a specificity nobody chose. The source doesn’t show you the selector you actually wrote, so you can’t tell what you’ve written without going and reading the compiled file. And it couples the CSS to the DOM, so a markup change breaks styling in a file nobody opened.

All three survive intact, and I think one of them got worse, since & resolving like :is() means the specificity nobody chose isn’t even the one you’d have guessed. Three levels of nesting produces .page .card .card__body a whether a build step wrote it or the browser did.

The middle complaint is the one I had about @extend eight years ago, that the file you’re reading looks correct while the damage sits in a file you aren’t. Native nesting is a little better on that, there being no separate compiled file at all, and it doesn’t fix it, because the selector you end up with still isn’t written down anywhere.

So the rule I landed on nearly a decade ago is the same rule. Nest for state and context, never for structure: a pseudo-class, an attribute state, a media query, a :has() condition. Not a hierarchy. One level, occasionally two, and at three you’ve drawn a picture of your markup in your stylesheet and one of the two is going to move.

I know, I know, sometimes the third level really is the shortest way to say the thing. I write one about twice a year and leave a comment above it saying why, which is either discipline or a confession, depending on the day.

What’s left of the preprocessor

Nesting was one of about five reasons I was running SCSS at all. It’s another leg off.

Variables went native in 2016 and the native ones are better than the SCSS ones for anything that changes at runtime. The ordering problem I used to solve by being careful about import order is @layer now. Color functions keep arriving. That leaves loops, math at build time, generating something from a map, and concatenating files, and the last of those is a bundler’s job on most projects anyway.

That’s a real list and it’s a much shorter one than it was, and on two projects this year the honest answer was that nothing on it applied. So I use less of it every year and I’ve removed it from nothing, which is roughly where I land on every tooling question I write up tbh

The file I ended up with

The fix took about 10 minutes and it was the same fix everywhere, which is that I wrote the class names out.

.card__title is a string that exists in the file now, so ctrl+f finds it, which is the thing I said out loud in 2015 and then spent 2 years not doing. The hover state, the focus state and one breakpoint stay nested, each inside the rule it modifies, and the component reads better than either version of it I’ve had.

I keep coming back to how little of that was a decision. I typed what I’ve always typed, the browser dropped it without a word, and what I ended up with is the convention I argued for a decade ago and had stopped following without once noticing. That isn’t discipline, it’s a compiler being unavailable. I’ve since been through the other conventions I’d tell you I hold, looking for which ones are still enforced by something outside my own memory. It’s fewer than I’d have guessed, and the ones nothing enforces are the ones I ought to write down somewhere the next person will find them.

Read similar posts
9 min

I named it after the page

A landing page needed one block to sit closer to the heading above it, and the modifier I wrote to do that had the name of a page in it.

10 min

Picking the green on a call

A client spent most of a call deciding whether their green was too cold, and I spent that same call in DevTools changing the green one rule at a time.