---
name: css-nesting
description: "Writes and refactors native CSS nesting: the & selector, nested @media and @container, declarations after nested rules, bare type selectors, Sass-style &__suffix that silently fails, nested selector lists that gain id weight, and depth. Runs a read-only linter and a computed-style equivalence check in Chromium. Use for 'convert to nested CSS', nesting not applying, SCSS to native CSS. Do NOT use for Sass compilation, @layer order, :has() or :is() design, or PostCSS setup."
license: CC-BY-4.0
metadata:
  author: Afeez Adebayo
  author_url: https://afeezadebayo.com
  author_linkedin: https://www.linkedin.com/in/afeez-adebayo/
  author_x: https://x.com/za_icon
  source: https://afeezadebayo.com/skills-md/frontend-development/css-layout/css-nesting/
  category: frontend-development
  subcategory: css-layout
  version: "1.0.0"
  created: "2026-10-30"
  updated: "2026-10-30"
---

# CSS Nesting: Write Native Nested CSS That Behaves

Work in this order: lint the stylesheet, decide what is worth nesting, write declarations first and nested rules after, settle the browser support line, then prove a refactor changed no computed style by comparing the old and new sheets in Chromium. Do not trust a nested rewrite until `equiv_check.py` reports 0 differences.

Measured values and source notes are in `references/appendix.md`.

## When to use this skill, and when not to

Use it to convert flat CSS to nested CSS, to write new nested components, or to find out why a nested rule has no effect.

| Request | Use instead |
|---|---|
| Specificity of `:is()`, `:has()` and `:where()` selectors | `modern-css-selectors.md` |
| `@layer` order and which layer wins | `cascade-layers.md` |
| Container query syntax and sizing | `container-queries.md` |
| SCSS, Less or PostCSS configuration and builds | Out of scope. Ask for the compiled CSS |
| Naming convention debates (BEM, utility classes) | Out of scope |

## Guardrails

1. **Propose, then change.** Report and plan first. Edit only after the user approves a batch, on a branch. Never push, merge or deploy.
2. **Bulk edits need confirmation.** Converting more than one stylesheet, or more than 10 rules, is shown as a diff first. Keep the original file until `equiv_check.py` passes.
3. **Data handled.** The tools read CSS and HTML files you pass. `nesting_lint.py` prints selectors and line numbers. `equiv_check.py` prints element paths and property names, never text, field values or storage. Open only pages the user owns, with test data. Never request session cookies or tokens.
4. **Nothing is invented.** Every rule carries a source or a measurement. Version-dependent behaviour carries **verify before relying on this**.
5. **Not a conformance statement.** A clean run means the checked page renders the same in Chromium. Safari and Firefox need their own check.
6. **Refuses to:** nest SCSS syntax as if it were CSS, convert a file that has no equivalence check available without telling the user, claim browser support from memory, or fix a nesting problem by adding `!important`.

## Inputs the agent must have before starting

Ask for every missing item. Do not assume.

| Input | Ask exactly |
|---|---|
| Files | "List the stylesheets, in load order. Are any of them compiled from Sass or Less?" |
| Goal | "Convert existing CSS, write new nested CSS, or fix a rule that does not apply?" |
| Browsers | "Which browsers and minimum versions must work? Is there any engine older than Chrome 120, Safari 17.2 or Firefox 117?" |
| Markup | "Give me a page of markup I may open locally, without the stylesheets under test linked in it." |
| Depth | "Is a nesting depth limit of 3 acceptable, or is there a house limit?" |
| Tooling | "Is Python 3 installed? Is Playwright with Chromium installed for the equivalence check?" |
| Approval | "May I edit on a branch in batches and show each diff first?" |

## Workflow

### Step 0: Confirm scope

If the source is Sass or Less, stop and ask for the compiled output or confirm a one-time migration. Native nesting is not a drop-in for preprocessors: see step 3.

### Step 1: Run the linter

```
python3 scripts/nesting_lint.py styles.css | head -40
```

Read only its output, not the script. The first line has the counts. `--legacy` also flags syntax that needs Chrome 120, Safari 17.2 or Firefox 117. `--max-depth N` changes the depth limit (default 3). `--strict` exits 1 on warnings.

| Code | Level | Meaning | Fix |
|---|---|---|---|
| `scss-syntax` | error | `//` comments, `$vars` or `@mixin` in a `.css` file | Step 0 |
| `amp-ident` | error | `&` directly followed by a name, such as `&__title` or `&-open` | Step 3, rule 4 |
| `amp-pseudo-element` | error | `&` nested under a pseudo-element parent | Step 3, rule 6 |
| `decl-after-rule` | warn | A declaration after a nested rule | Step 3, rule 1 |
| `bare-type` | warn (`--legacy`) | A nested type selector with no `&` | Step 3, rule 3 |
| `id-list-amp` | warn | A parent list containing an id, with `&` in a child | Step 3, rule 5 |
| `depth` | warn | Nesting deeper than the limit | Step 2 |
| `amp-top-level` | info | `&` outside any parent | Delete it or add the parent |

The linter does not run scripts or resolve the cascade. Step 5 covers behaviour.

### Step 2: Decide what to nest

| Situation | Decision |
|---|---|
| Two or more rules share the same parent compound selector (`.card`, `.card:hover`, `.card .title`) | Nest them under it |
| A state of the component (`:hover`, `[aria-expanded="true"]`, `.is-open`) | Nest with `&` |
| The component changes under a parent context (`.dark .card`) | Nest as `.dark & { }` inside `.card` |
| A media or container rule changes only this component | Nest the at-rule inside the component |
| A selector reaches into another component | Do not nest. Keep it flat |
| Depth would exceed 3 | Flatten the inner part with a class. The 3 is a house limit, not a spec rule: the spec sets none (Tiffany Brown, 2023) |
| BEM elements written as `&__title` | Write `.card__title` flat, or `.card { .card__title { } }` if the author wants grouping |

### Step 3: Write the rules

1. **Declarations first, then nested rules.** Before Chrome 130, a declaration placed after a nested rule was moved up and could lose to the nested rule at equal specificity. The fix shipped in Chrome and Edge 130, Safari 18.2 and Firefox 132 (web.dev, 8 October 2024). Putting declarations above nested rules works in every nesting-capable browser. **Verify before relying on this** for the version numbers.
2. **`&` placement.** `&` stands for the parent selector. `&:hover`, `&.is-open`, `& > p`, `.dark &` and `& + &` are valid. It can appear more than once and anywhere in the selector.
3. **Type selectors.** Nested `p { }` with no `&` works from Chrome 120, Safari 17.2 and Firefox 117 (Chrome for Developers, updated 9 November 2023). Older engines need `& p`. Write `& p` unless the support line from Inputs clears all three versions.
4. **No suffix joining.** `&__title` and `&-open` do not join strings as Sass does. Measured in Chromium 153: the rule was ignored and the element kept its inherited colour. The following declaration in the same block still applied, but Tiffany Brown (2023) reported that an invalid nested rule could stop later siblings in older builds, so fix every `amp-ident` row.
5. **Lists gain weight.** A nested `&` behaves like `:is(parent list)`. The CSS Working Group states it as: `.foo, #bar { & { color: green } }` gets id specificity even when only `.foo` matches (CSSWG issue 8738). Measured in Chromium 153: for `.d, #dd { & { color: red } }` followed by `.d { color: green }`, an element with class `d` and no id turned red. Split the list or drop the id. Use `modern-css-selectors.md` to compute the weight.
6. **Pseudo-element parents.** Brown (2023) reports that `&:hover` under `::before` is ignored. **Verify before relying on this** in your support line. Put the state on the real element.
7. **At-rules.** `@media`, `@supports`, `@container` and `@layer` nest inside a rule and apply as if following the parent (Brown). Measured: a nested `@media` containing bare declarations changed `padding` correctly. A nested rule inside `@layer` still lost to an unlayered rule at lower specificity: see `cascade-layers.md`.
8. **Keep nested depth low.** Each level adds the parent's weight. `.a { .b { .c { } } }` weighs the same as `.a .b .c`.

### Step 4: Fix the support line, not the CSS

If every browser in Inputs clears Chrome 120, Safari 17.2 and Firefox 117, ship native nesting. If any does not, do not hand-write fallbacks: Brown measured that an `@supports selector(&)` fallback roughly doubles the CSS sent. Tell the user the choice is to raise the support line or compile nesting out with their build tool, and stop.

### Step 5: Prove equivalence

For a flat to nested refactor, keep the old sheet and run:

```
python3 scripts/equiv_check.py page.html old.css new.css
```

`page.html` is markup only. The script loads it twice, once per sheet, compares every element's computed style and prints the element count, the number that differ and up to 20 paths with property names. Exit 0 means no difference.

| Output | Action |
|---|---|
| `0 differ` | Done for this page |
| Differences with `font-`, `color` or `margin` properties on the elements you edited | A rule was dropped or reordered. Run step 1 again and read the `amp-ident` and `decl-after-rule` rows |
| Differences on the parent only in `height`, `block-size`, `perspective-origin` or `transform-origin` | A child changed size, so the parent derived values changed. Fix the child first |
| Differences on elements you did not edit | A selector got broader or heavier. Check `id-list-amp` and the specificity of the rewritten rule |

A page that never triggers hover, focus or a media query cannot prove those rules. List them as untested, or ask the user for markup states, such as a class that forces the state.

### Step 6: Report

Run the linter again. The error count must be 0. Report a list of file, line, code and fix. Do not echo unchanged CSS. List what was not tested: Safari, Firefox, hover and focus states, print, and breakpoints beyond the one used.

## Worked example

An invented card stylesheet, written in Sass habit:

```css
.card {
  padding: 1rem;
  border: 1px solid #ccc;
  &__title { font-size: 1.25rem; margin: 0; }
  &__body { color: #333; }
  &__link:hover { text-decoration: underline; }
  @media (min-width: 40em) { padding: 2rem; }
}
```

```
$ python3 scripts/nesting_lint.py nested.css
nesting_lint: 3 error, 0 warn, 0 info in 1 file(s)
nested.css:4 error amp-ident | &__title | '&' directly followed by a name is not CSS ...
nested.css:5 error amp-ident | &__body | ...
nested.css:6 error amp-ident | &__link:hover | ...
$ python3 scripts/equiv_check.py page.html flat.css nested.css
equiv_check: 8 elements compared, 3 differ
html>body:nth-child(2)>div#c1 | block-size, height, perspective-origin, transform-origin
html>body:nth-child(2)>div#c1>h2#t1 | block-size, font-size, height, margin-block-end, margin-block-start, margin-bottom +3
html>body:nth-child(2)>div#c1>p#b1 | border-block-end-color, border-block-start-color, ...
```

The title and body lost their rules, and the card's own size changed as a result. Batch one, approved by the user, keeps the nesting for the parent state and flattens the BEM children:

```css
.card {
  padding: 1rem;
  border: 1px solid #ccc;
  @media (min-width: 40em) { padding: 2rem; }
  .card__title { font-size: 1.25rem; margin: 0; }
}
.card__body { color: #333; }
.card__link:hover { text-decoration: underline; }
```

```
$ python3 scripts/equiv_check.py page.html flat.css nested.css
equiv_check: 8 elements compared, 0 differ
```

The check ran at Playwright's default 1280 px viewport with no hover state, so the narrow-viewport padding and the `:hover` rule are untested and go in the report.

## Templates

**Report format**

```
css-nesting report: <files>, <date>
Lint: <errors> error, <warn> warn. Support line: <browsers>.
1. <file>:<line> <code> -> <fix applied or proposed>
Equivalence: <n> elements, <m> differ, viewport <w>px.
Not tested: Safari, Firefox, <states>, <breakpoints>
Needs approval: <batch list>
```

**Starting prompt**

```
Use css-nesting. Files: <paths>, load order <order>. Goal: <convert|write|fix>.
Browsers: <list>. Markup for testing: <path>, no stylesheets linked.
Lint first, show findings, then propose a batch. Do not edit until I approve.
```

## Failure modes and anti-patterns

| Failure | How to spot it | Do instead |
|---|---|---|
| Sass-style `&__x` or `&-x` | `amp-ident`; the element is unstyled | Write the full selector |
| Declaration after a nested rule | `decl-after-rule`; the nested rule wins in older engines | Move declarations above nested rules |
| Id in a parent list with a nested `&` | `id-list-amp`; the nested rule overrides everything | Split the list, drop the id |
| Nesting five levels deep | `depth`; overrides need more weight each time | Flatten with a class |
| Assuming old limits still apply, or new features everywhere | The 2023 articles say bare type selectors are invalid; the 2023 Chrome article says they work from 120 | Check the support line in Inputs, then use step 3, rule 3 |

## Quality bar

Run every item. Each is a command or a count.

1. `nesting_lint.py` on all touched files prints `0 error`.
2. No `decl-after-rule` warning remains.
3. No `amp-ident` or `amp-pseudo-element` row remains.
4. For a refactor, `equiv_check.py` prints `0 differ`, or each difference is named and accepted.
5. The report lists untested states and viewports.
6. Every support claim carries a source and a date or a measurement.
7. No file was edited without an approved diff, and no push, merge or deploy happened.
8. The report is a list of file, line, code and fix, with no unchanged CSS repeated.

## Cost

Estimates from the skill's own count (words times 1.35 for prose, characters divided by 3.5 for code and tables), not a model tokeniser.

| Item | Tokens | Read when |
|---|---|---|
| Description | 99 | Always loaded |
| This file | 3,640 | The skill fires |
| `references/appendix.md` | 782 | The user asks for measured values or source notes |
| Script output | 350 | Each run |
| Typical run | 4,089 | Description, this file, script output |
| Worst case | 6,971 | Everything above plus 20 lines of `equiv_check.py` output |

Cheaper path: run `nesting_lint.py`, fix only flagged lines, skip the appendix and the equivalence check on new code.

## Tool-specific appendix

`equiv_check.py` uses Playwright for Python with Chromium. It compares computed styles at Playwright's default 1280 by 720 viewport, so pass markup that forces the state you need. For older engines, compile nesting out with the project's build tool, such as PostCSS with a nesting plugin; this skill does not configure builds.

## Sources

Last verified 30 October 2026.

- Anthropic, Skill authoring best practices: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
- Chrome for Developers, CSS nesting relaxed syntax update: https://developer.chrome.com/blog/css-nesting-relaxed-syntax-update
- web.dev, CSS nesting improves with CSSNestedDeclarations: https://web.dev/blog/css-nesting-cssnesteddeclarations
- Tiffany B. Brown, Native CSS nesting: https://tiffanybbrown.com/2023/04/native-css-nesting/
- CSS Working Group issue 8738 discussion: https://lists.w3.org/Archives/Public/public-css-archive/2023Oct/0570.html

Use, adapt and share under CC BY 4.0: credit Afeez Adebayo, link https://afeezadebayo.com and https://www.linkedin.com/in/afeez-adebayo/. Download URL: https://afeezadebayo.com/wp-content/uploads/skills-md/frontend-development/css-layout/css-nesting/SKILL.md
