CSS from the ground up

Nesting, :has() and container queries

The three additions that changed how stylesheets are written: rules inside rules, a selector that sees what an element contains, and a component that answers to its own width.

Part 12 of 12

Advanced17 min readBack to Courses

Rules inside rules

A nested rule is written inside another one, and its selector is completed by the rule around it: .card { h2 { … } } means a heading inside a card. The & stands for the parent itself, which is how a rule says &:hover, & h2, or the two-of-the-same kind of selector.

Media queries nest too, so a component's small-screen rules can live beside the rest of it: .card { @media (min-width: 40rem) { … } }. Every evergreen browser has understood nesting since 2023, so a build step is no longer part of the answer.

Nesting is a convenience, and convenience has a limit. Two levels read clearly; four levels mean the selector is describing a structure rather than a component, and a class on the inner element would say the same thing in fewer words. The & is most useful for states and for compound selectors such as &.is-open, where repeating the parent name would be noise.

:has() looks down before it styles up

For most of its life CSS could only travel forwards — choose a child, a sibling, a descendant. :has() reverses one step: article:has(.tag) chooses the article because of what is inside it, and styles the container from the content.

Use it for the state that belongs to the container: a field with an error inside, a card with an image, a list with a selected row. The cost is specificity and support — it weighs like the class it is attached to, and it arrived in browsers through 2023.

A component answers to its own width

A media query asks about the window. A container query asks about the component's own box: give an element container-type: inline-size, and inside it write @container (min-width: 20rem) { … }. The rules apply when that box is wide enough — the viewport is not consulted.

A card in a grid is the honest example: the same card is wide in one layout and narrow in another at the same window size. With a container query the card changes with its slot, which is what makes it reusable in both.

One component, all three ideas

.cards {
  container-type: inline-size;
}

.card {
  border: 1px solid #d4d8e0;

  & h2 { margin: 0 0 0.5rem; }
  &:hover { border-color: #0b6b47; }
  &:has(.tag) { border-color: #0b6b47; border-width: 2px; }
}

@container (min-width: 20rem) {
  .card { padding: 1.5rem 2rem; }
}

What nesting does to a selector

A nested rule is shorthand for a longer selector, not a new scope. .card { h2 { … } } means .card h2 — a descendant, at any depth. If you mean the card itself in a state, write &:hover; without the ampersand, :hover alone would mean a descendant of the card being hovered.

Nested at-rules keep their own meaning too. A @media inside .card still asks about the viewport, and a @container inside it asks about the nearest container. Nesting only saves you from repeating the parent selector; it does not change what the query measures.

The cost of looking down

:has() lets a selector travel from a container to its contents, which the language could not do before. The browser has to re-evaluate it when a descendant changes, so it is best used for a small, stable piece of state — a card with a tag, a field with an error — rather than as the main way to style a large list.

A container query needs a container. container-type: inline-size makes an element answer questions about its inline size, and the query then styles what sits inside that box, which is why the common pattern is a wrapper with the type and the component inside it. The viewport is never consulted, so the same component can change in a narrow slot while the window stays wide.

Both features arrived in browsers through 2023, which is recent enough that a fallback is worth a thought. The common pattern is to write the base style first and let the modern selector add to it, so an older browser gets a usable page rather than an unstyled one. A feature query can ask whether the browser understands the rule, but for most sites the simpler answer is to make the base good enough on its own.

Three modern-CSS mistakes: nesting a state as :hover when you meant &:hover, which silently changes the selector to a descendant; writing @container without giving an ancestor container-type, so the query never matches; and reaching for :has() when the element already carries a class that says the same thing — a class is cheaper to match and easier to read.

Try these next

  • Convert one .card h2 rule to nesting, then add &:hover and check the generated selector in the inspector.
  • Add a wrapper with container-type: inline-size and change a child’s layout at 30rem.
  • Use :has() to outline a form field that contains an error message, then compare it with a class-based version.

Cards that know their place

Style the cards the modern way: nest the heading rule inside the card, let a card that contains a tag style itself, and add a container query so the cards answer to their own width.

Live Preview
Console output will appear here...

4 left to do

  • The heading rule is nested under the card
  • A card that holds a tag is chosen
  • The card shelf is a query container
  • A container query changes the card

Start with .cards { container-type: inline-size; }, nest the heading as .card { & h2 { … } }, add .card:has(.tag) { … } for the tagged cards, and finish with @container (min-width: 20rem) { .card { padding: … } }.

A panel that answers its slot

The panel sits in a narrow sidebar on a wide screen and across the page on a small one. Give its wrapper a container type, then use a container query so the panel’s heading and text sit side by side only when the panel itself is at least 30rem wide. Nest the heading rule inside the panel.

Live Preview
Console output will appear here...

3 left to do

  • The wrapper is a query container
  • A container query changes the panel
  • The heading rule is nested with the ampersand

.panel-wrap { container-type: inline-size; }, then @container (min-width: 30rem) { .panel { display: flex; gap: 1rem; } }. Nest the heading as .panel { & h2 { … } }.

Nesting is a convenience, not a puzzle. If a rule needs three levels to reach its element, a class on that element is usually the better answer — one level is the common depth, and & is most useful for states that need the parent name.

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does the ampersand mean inside a nested rule?