Modern CSS: variables, nesting, :has() and layers
Why:Today’s CSS does things that once needed preprocessors and scripts. Afterwards you’ll gather values into variables, nest rules, style a block by what it contains with :has(), and put your styles in order with layers.
30 minutes
Read by speech synthesis (Qwen3-TTS); the voice is not a person’s.
Modern CSS is a set of newer features (variables, nesting, :has() and @layer) that replaced preprocessors and scripts.
The new era: CSS in 2025
So far you’ve written CSS the way it was written in 2005 and 2014. But the language hasn’t stood still. In the last few years browsers have learned to do what once needed preprocessors (Sass, Less) and scripts. This lesson is about how people write CSS today. It belongs to our 2025 era: the page is the same, but the styles are rewritten.
We’re interested in four new things.
Variables
Custom properties, also called “CSS variables”, are declared with two hyphens and read through var():
:root {
--color-text: #1d2430;
--color-accent: #1a4d8f;
--space: 1rem;
}
.button {
color: white;
background-color: var(--color-accent);
padding: var(--space);
} :root is the html element itself, so variables declared there are visible across the whole page. The advantages:
- the value lives in one place. Want a different accent color? Change one line, not thirty;
- they’re live. Unlike a preprocessor’s variables, CSS variables stay in the browser. You can redefine them on any block (a card inside a dark block gets different colors) and change them from JavaScript;
- they inherit down the tree, like the text color does.
A good rule is to name variables by role, not by value: --color-accent, not --blue. Then in a year the accent can turn green and the name will still be true. Named values like these are called design tokens, and we’ll take them apart later, in the part on design systems.
A dark theme with variables is almost free: every color goes through a variable, and @media (prefers-color-scheme: dark) changes only their values. The rules for cards and buttons don’t need touching at all. In the head you also write <meta name="color-scheme" content="light dark">, which tells the browser the site has two themes, so that it doesn’t flash white while loading.
Move the sliders in the diagram: one card, the same rules, and the look changes because of the values. Below it there’s more about layers.
:root {
--color-bg: hsl(210 20% 99%);
--color-surface: hsl(210 25% 94%);
--color-text: hsl(210 30% 14%);
--color-accent: hsl(210 65% 32%);
--color-on-accent: hsl(210 20% 99%);
--radius: 8px;
--space: 1rem;
} To make the theme dark we didn’t rewrite a single card rule. We only changed the variables’ values, in one place.
| Selector | Specificity | color | Layer |
|---|---|---|---|
| #cta | (1, 0, 0) | red | |
| .button | (0, 1, 0) | blue | |
| a | (0, 0, 1) | green |
The link will be green: the winner is a, because it is outside every layer, and an unlayered rule beats any layer. The layer order is declared once: @layer base, components, utilities;
Check yourself: why is --color-accent a better name than --blue?
Because a role lasts longer than a value: tomorrow the accent may turn green, and the name will still be true.
Nesting
Before, to write the rules for a card and its insides you had to repeat the selector: .card { … }, .card h3 { … }, .card:hover { … }. Now you nest the rules inside each other:
.card {
padding: var(--space);
& h3 {
margin-top: 0;
}
&:hover {
background-color: var(--color-surface);
}
} The & means “the parent selector”. Nesting makes the file shorter and gathers everything about the card in one place. Don’t nest deeper than two or three levels: long chains hurt readability and specificity.
:has(): a parent by its child
The :has() selector selects an element by what lies inside it. CSS had no parent selector at all, and this trick used to seem impossible:
section:has(.cards) { padding-block: 1.5rem; } /* a section that has cards in it */
form:has(input:invalid) button { opacity: 0.7; } /* the button in a form that has an invalid field */
label:has(input:checked) { font-weight: 600; } /* the label of a checked checkbox */ :has() has worked in all browsers since the end of 2023. It’s one of the most useful new features: much of what used to be done with a script is now done by CSS.
Check yourself: what will form:has(input:invalid) select?
A form with at least one invalid field inside it. You can use it to style the button, for example.
Layers
The more CSS there is, the more often rules fight: remember the cascade from the first lesson. The fight ended with !important and ever longer selectors. Layers (@layer) set the order in advance:
@layer reset, base, components, utilities; One line at the top of the file declares the layers from weakest to strongest. Then each rule goes into its own layer:
@layer base {
a { color: var(--color-accent); }
}
@layer components {
.button { color: var(--color-on-accent); }
} The rule is simple. A later layer beats an earlier one, whatever the specificity. Specificity decides only inside one layer. And rules outside any layer beat every layer: so keep unlayered styles for exceptions, and put the bulk of your CSS into layers. You can try this by hand at the bottom of the diagram above.
This saves you from the “specificity war”: a #cta selector in the base layer can’t beat a plain .button from the utilities layer. Layers reached browsers in 2022.
Check yourself: which is stronger, a rule outside the layers or a rule from the last layer?
The rule outside the layers. It beats every layer, even the latest.
Practice
You’re starting the 2025 era. We’ve given you the page and the styles: it’s the result you finished in the last lesson (if you saved your own styles, you can paste them in). Your job is to turn the styles into modern ones: variables on :root, a dark theme, layers, nesting and :has().
The checks here can see the variables, the color-scheme tag and the :has() rule. They can’t see layers, nesting or the dark theme, because the browser hands them only computed values. Check those three by eye: open the developer tools and find your layers in the “Styles” pane, and turn on the dark theme in the “Rendering” panel (the option “Emulate CSS media feature prefers-color-scheme”).
What was wrong with 2014
Back then it was almost impossible to write a big stylesheet without Sass or Less: you needed variables, nesting and mixins. A preprocessor turned your file into plain CSS on the server, and the browser saw only the finished result. Today the browser can do half of that itself. The other half of the problem was specificity and !important, until layers replaced them.
Practice
Preparing your page…
Test
Question 1 of 6
Cards
These cards go into your review once you finish the lesson.
How do you declare and use a CSS variable?
Answer
Declare: --color-accent: #1a4d8f; (usually on :root). Use: color: var(--color-accent). Variables stay live in the browser and inherit.
How do you make a dark theme?
Answer
Put every color in a variable, and let @media (prefers-color-scheme: dark) change only the variables’ values on :root. Add a color-scheme meta tag with the content light dark.
What is nesting in CSS?
Answer
A rule inside a rule: .card { & h3 { … } &:hover { … } }. The & stands for the parent selector. It used to take a preprocessor.
What does :has() do?
Answer
It selects a parent by its contents: section:has(.cards), form:has(:invalid). CSS had no parent selector before.
What is @layer, and how do layers compete?
Answer
@layer reset, base, components, utilities; declares the order once. A later layer beats an earlier one whatever the specificity, and rules outside any layer beat them all.
Sources
- Using CSS custom properties (variables) (MDN) CC BY-SA, in English
- :has() CSS pseudo-class (MDN) CC BY-SA, in English
- @layer (MDN) CC BY-SA, in English
- CSS nesting (MDN) CC BY-SA, in English
- Nesting (web.dev) CC BY, in English
- CSS Cascading and Inheritance Level 5: cascade layers (W3C) link only, in English
Next
You now know: custom properties, CSS nesting, the :has() selector, cascade layers.