Native nesting and the ampersand
Of the three ways native nesting fails without telling you, the ampersand that isn't text is the one that sent me back to a convention I'd argued for in 2015.
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 three 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 didn’t change anything else, which was the entire point.
Nothing was crossed out in DevTools, 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. There’s no line in the pane to inspect and no complaint in the console.
That’s a different silence from the one last week, where the rule was perfectly valid and just being read in the wrong room. Here the declarations on .card itself kept applying, so I was looking at a half-assembled component instead of a missing one.
.card {
/* these applied */
border-radius: 4px;
padding: 1rem;
/* this was never a rule at all */
&__title {
font-size: 1.25rem;
}
}
I’ve typed that shape for years and it has never once not worked. Both things are called nesting and the syntax is ostensibly identical, which is how you end up with 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 the output file gets a flat class selector. It’s a text transform on the way to a stylesheet, which I think is 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, and the browser resolves it as it parses. It’s as real as :hover, which isn’t six characters waiting to be pasted somewhere either.
So &__title isn’t a name being assembled. It’s & with __title stuck on the end. 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 there to concatenate to, so nothing anybody adds later could make it work.
I did warn me
I’d already made this argument myself, which is the embarrassing part.
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. 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’ve been writing it perfectly happily ever since. I didn’t 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 something 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(), so 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.
.button, #cta {
/* weighs what #cta weighs, even on the element that matched .button */
& .icon {
fill: currentColor;
}
}
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. Then it’s genuinely baffling because the rule in front of you looks like two classes and it’s beating things two classes can’t 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. This is that same complaint arriving from a new direction.
Last call for declarations
The third one is temporary but quiet in its own way, so it’s the one I’d warn a team about first.
Declarations at the same level that come after a nested rule don’t stay where you put them. The browser reads them as though they’d been written at the top of the block, above every nested rule. So anything nested that sets the same property wins no matter how far down you put the override.
.card {
padding: 1rem;
@media (min-width: 40em) {
padding: 2rem;
}
/* added later and meant to win, but read as if it sat above the media query */
padding-inline: 1.5rem;
}
The media query is fine, and so is everything inside it. Only the loose declaration after it moves. Nothing gets dropped, which almost makes it worse. The override works on a phone and loses on a laptop to a media query sitting above it in the file.
The spec says the order between declarations and nested rules isn’t preserved. The working group agreed last fall to change that, but no browser has the change yet.
Until that’s everywhere, 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. Not that anybody is going to remember it at 5 pm.
The bottom of the file is gone
The two uses that were always the good part of nesting cost nothing now.
One is keeping states next to the thing they modify. &:hover, &:focus-visible, &[aria-expanded="true"] and &:disabled sit 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.
The other is keeping media queries inside the component, and that’s the one I’d have paid money for.
.card {
display: block;
padding: 1rem;
&:hover {
border-color: var(--accent);
}
@media (min-width: 40em) {
display: grid;
grid-template-columns: 8rem 1fr;
}
}
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’ve ever written has had that block in it, and it’s always been the worst part.
The first implementations wanted a symbol in front of a nested element selector and the current ones don’t. That changed over the winter, so a good deal of what you’ll find written about this describes 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 & resolves like :is(), 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, since there’s no separate compiled file at all. It doesn’t fix it, though. The selector you end up with still isn’t written down anywhere.
So the rule I landed on nearly a decade ago hasn’t changed. Nest for state and context, never for structure: a pseudo-class, an attribute state, a media query, a :has() condition. Not a hierarchy. Go one level deep, occasionally two. 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. Depending on the day, that’s either discipline or a confession.
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. The last of those is a bundler’s job on most projects anyway. That’s a real list and a much shorter one than it was. On two projects this year nothing on it applied at all.
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 everywhere. I wrote the class names out.
.card__title is a string that exists in the file now, so ctrl+f finds it. That’s 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. 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 and the browser dropped it without a word. I ended up with 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. Nothing enforces the rest, so I ought to write them down somewhere the next person will find them.