---
name: modern-css-selectors
description: "Audits and fixes CSS selectors: :has() parent and sibling styling, :is() and :where() grouping, :not() lists, specificity surprises, silent typos in forgiving lists and slow :has() anchors on body or :root. Runs a read-only linter and specificity calculator, then counts matches in Chromium. Use for 'style the parent', ':has() not working', ':is vs :where', specificity bugs. Do NOT use for @layer order, nesting syntax, container queries or JavaScript selector engines."
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/modern-css-selectors/
  category: frontend-development
  subcategory: css-layout
  version: "1.0.0"
  created: "2026-10-30"
  updated: "2026-10-30"
---

# Modern CSS Selectors: :has(), :is(), :where() and :not() Without Surprises

Work in this order: run the linter over the CSS and HTML, compute the specificity of any selector you plan to change, pick the selector function with the decision table in step 2, rewrite, then prove it in Chromium by counting matches and re-running both tools. Never change a selector's specificity by accident: print it before and after.

Recipes are in `references/patterns.md`. Measured browser behaviour and source notes are in `references/appendix.md`.

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

Use it when a rule must depend on a child, a following sibling or a group of selectors, when a selector list grew long, when an override loses to a rule it should beat, or when a `:has()` rule is slow or silently ignored.

| Request | Use instead |
|---|---|
| Which layer wins, `@layer` order, `!important` in layers | `cascade-layers.md` |
| Nested rules, `&`, declarations after a nested rule | `css-nesting.md` |
| Style by container width | `container-queries.md` |
| Choosing the right element, heading or landmark | `semantic-html.md` |
| Form validity styling and error text | `html-forms.md` |
| Selector engines in JavaScript, `querySelector` performance | Out of scope |

## Guardrails

1. **Propose, then change.** Report findings and a rewrite plan first. Edit only after the user approves a batch, on a branch. Never push, merge or deploy.
2. **Bulk edits need confirmation.** A rewrite that touches more than one stylesheet, or more than 10 rules, is shown as a diff first.
3. **Data handled.** The tools read CSS and HTML files you pass. `selector_lint.py` prints selectors and line numbers. `match_count.py` prints counts and selectors, never page text, form values or cookies. Pages may contain personal data: do not request screenshots, and never paste page content into a report. Open only pages the user owns, with test data.
4. **Nothing is invented.** Every rule carries a source or a measurement. Behaviour that depends on a browser version carries **verify before relying on this**. Do not state support from memory.
5. **Not a conformance statement.** A clean run means the checked selectors behave in Chromium. Safari and Firefox need their own check.
6. **Refuses to:** put `:has()` on `html`, `body`, `:root` or `*` without a written reason from the user, hide state that belongs in JavaScript inside a `:has()` hack, raise specificity with an id to win a fight, or report a rewrite as verified without the match counts.

## Inputs the agent must have before starting

Ask for every missing item. Do not assume.

| Input | Ask exactly |
|---|---|
| Files | "List the stylesheets and templates, in load order." |
| Symptom | "Which element should match, and which rule is winning instead? Give the selector or the line." |
| Browsers | "Which browsers and minimum versions must work? Is there an engine older than December 2023 in your analytics?" |
| Page | "Which page may I open locally to count matches? Use a file or a URL you own, with test data." |
| Tooling | "Is Python 3 installed? Is Playwright with Chromium installed for the match counts?" |
| Approval | "May I edit on a branch in batches and show each diff first?" |

## Workflow

### Step 0: Confirm scope

List the stylesheets in load order. If the project compiles Sass or PostCSS, lint the compiled output: the linter reads CSS and `<style>` blocks only.

### Step 1: Run the linter

```
python3 scripts/selector_lint.py styles.css page.html | head -40
```

Read only its output, not the script. The first line has the counts. Rows are sorted errors, warnings, info. `--strict` exits 1 on warnings. Add `--legacy` only when the browser list includes engines without `:has()`.

| Code | Level | Meaning | Fix |
|---|---|---|---|
| `has-nested` | error | `:has()` inside `:has()` is invalid | Step 3 |
| `has-pseudo-element` | error | A pseudo-element inside `:has()` is invalid | Step 3 |
| `has-visited` | error | `:visited` inside `:has()` never matches | Step 3 |
| `has-broad-anchor` | warn | Anchor is `html`, `body`, `:root`, `*` or empty | Step 3 |
| `has-in-list` | warn (`--legacy`) | An unsupported `:has()` drops the whole list | Step 3 |
| `is-id-specificity`, `not-id-specificity` | warn | An id inside the function sets the weight of the whole selector | Step 4 |
| `is-uneven-list` | info | Members differ in specificity; the heaviest applies to all | Step 4 |
| `has-descendant` | info | Unbounded descendant test | Step 3, only if the relation is direct |
| `legacy-any` | info | `:matches()` or a vendor alias | Replace with `:is()` |

The linter does not run scripts, resolve the cascade or detect typos inside forgiving lists. Step 5 covers those.

### Step 2: Choose the function

| Need | Use | Rule |
|---|---|---|
| Style a parent from a child, or an element from the next sibling | `:has()` | Anchor on a component class. Relation inside `:has()` starts with `>` or `+` when direct |
| Group three or more selectors that repeat a prefix or suffix | `:is()` | Compute `--spec` before and after. Heaviest member sets the weight |
| Defaults that any later rule must beat (resets, base components, libraries) | `:where()` | Specificity is zero, so one class wins |
| Exclude one or more cases | `:not(a, b)` | Specificity of the most specific argument. Prefer one list to a chain of `:not()` |
| Two conditions on a child (AND) | `:has(a):has(b)` | Chain. Not `:has(a b)`, which is a descendant |
| Either of two children (OR) | `:has(a, b)` | One list |
| Count-based layout (quantity query) | `:has(:nth-child(N))` | See `references/patterns.md`. Check it needs no JavaScript fallback |
| State owned by application code (open, selected, loading) | A class or `aria-*` attribute from JavaScript | Do not infer it with `:has()` on a distant ancestor |

If a rewrite changes specificity, say so in the report and get approval. A rewrite that must keep its weight uses `--spec` to prove it.

### Step 3: Write `:has()` rules

1. **Anchor narrowly.** MDN advises against anchors such as `body`, `:root` and `*`, and prefers `.container:has(> .foo)` (verified 30 October 2026). Browsers must re-check the subtree when it changes. If the state belongs to the whole page, toggle a class on `html` from JavaScript.
2. **Bound the relation.** `.card:has(> img)` stops at children. `.card:has(.badge)` walks every descendant. Use the unbounded form only when depth varies.
3. **Never nest or use pseudo-elements.** `:has()` cannot contain `:has()` or a pseudo-element, and `:visited` inside it always fails (Chrome for Developers). Chain `:has(a):has(b)` for AND. Test the real element, not `::before`.
4. **Support.** MDN lists `:has()` as widely available since December 2023. **Verify before relying on this** if the browser list in Inputs reaches older engines. Only then add a fallback: the base style works with no `:has()`, and the enhancement sits in its own rule. Do not put `:has()` in a comma list with other selectors, because an engine that cannot parse it drops the whole rule.
5. **Specificity.** `:has()` takes the weight of its most specific argument. `.a:has(#b)` is `(1,1,0)`.
6. **Accessibility.** A `:has()` rule that changes layout, visibility or focus appearance needs a keyboard check, because the Chrome team warns some demos are not production ready. Use `keyboard-focus.md` for the check.

### Step 4: Write `:is()` and `:where()` rules

1. **`:is()` keeps the heaviest member.** `:is(.a, #b)` is `(1,0,0)` for every element it matches, including those matched by `.a`. Remove ids or split the rule.
2. **`:where()` is always `(0,0,0)`.** Use it for anything a later author rule should override. `:where(.a .b)` is `(0,0,0)` as a whole.
3. **Both are forgiving.** An invalid member is ignored and the rest still match, so a typo such as `.crad` produces no error. Count each member separately in step 5.
4. **Do not use `:is()` to save characters.** Use it when the list repeats a prefix or suffix three or more times, or when it removes a source-order fight.

### Step 5: Prove it in Chromium

```
python3 scripts/match_count.py page.html '.card:has(.badge)' '.card' ':is(.card, .crad)' '.crad'
```

Counts print as `count<TAB>selector`. Rules:

| Check | Pass |
|---|---|
| Each member of every new `:is()` or `:where()` list, counted alone | Every member that should match something returns above 0 |
| New selector against the old one | Same count, or a difference you can name |
| `INVALID` in the output | A selector the browser rejects. The script exits 1. Fix before continuing |
| `--spec` before and after | Equal, or the change is in the report |

If Playwright is not available, ask the user to run `document.querySelectorAll(selector).length` for each selector in DevTools and paste the numbers.

### Step 6: Re-run and report

Run the linter again. The error count must be 0. Report as a list: file, line, code, fix. Do not echo unchanged CSS. List what was not tested: Safari, Firefox, print styles, and states a script toggles at run time.

## Worked example

An invented product page has this stylesheet in a `<style>` block:

```css
body:has(.cart-open) { overflow: hidden; }
.card:has(.badge) .title { color: #b00; }
:is(.card, #promo) .title { font-size: 1.25rem; }
.card .title { font-size: 1rem; }
```

```
$ python3 scripts/selector_lint.py page.html
selector_lint: 0 error, 2 warn, 1 info in 1 file(s)
page.html:2 warn has-broad-anchor | body:has(.cart-open) | anchor is body, ...
page.html:4 warn is-id-specificity | :is(.card, #promo) .title | ... an id makes the whole selector id-weight ...
page.html:3 info has-descendant | .card:has(.badge) .title | ...
$ python3 scripts/selector_lint.py --spec ':is(.card, #promo) .title'
(1,1,0)  :is(.card, #promo) .title
$ python3 scripts/selector_lint.py --spec '.card .title'
(0,2,0)  .card .title
```

The user's complaint was that the `.card .title` rule at 1rem never applies. The reason is now visible: the `:is()` rule weighs `(1,1,0)` for every card, so it beats `(0,2,0)` everywhere, not only on `#promo`.

Proposed batch, approved by the user. Guardrail 6 forbids a `:has()` anchor on `body`, so the first rule changes owner: the cart script toggles a `cart-open` class on `<html>`, and the CSS styles that class. The other two rules are rewritten in place:

```css
html.cart-open { overflow: hidden; }
.card:has(> .badge) .title { color: #b00; }
:is(.card, [id="promo"]) .title { font-size: 1.25rem; }
.card .title { font-size: 1rem; }
```

`[id="promo"]` is an attribute selector, so the `:is()` rule now weighs `(0,2,0)`, ties with `.card .title`, and the later rule wins by source order, which is what the user wanted. Re-run:

```
selector_lint: 0 error, 0 warn, 0 info in 1 file(s)
1	.card:has(> .badge)
2	.card
1	[id="promo"]
```

Every member that should match returns above 0, and the lint is clean. The `.card:has(> .badge)` count of 1 matches the one badge on the page.

## Templates

**Report format**

```
modern-css-selectors report: <files>, <date>
Lint: <errors> error, <warn> warn. Specificity changes: <list or none>.
1. <file>:<line> <code> -> <fix applied or proposed> (spec (a,b,c) -> (a,b,c))
Match counts: <selector>=<n> ...
Not tested: Safari, Firefox, <states set by script>
Needs approval: <batch list>
```

**Starting prompt**

```
Use modern-css-selectors. Files: <paths>, load order <order>. Symptom: <selector or rule>.
Browsers: <list>. I own page <path or URL>. Run the linter first, show findings and
the specificity of every selector you intend to change. Do not edit until I approve.
```

## Failure modes and anti-patterns

| Failure | How to spot it | Do instead |
|---|---|---|
| `:has()` anchored on `body`, `html` or `*` for page state | `has-broad-anchor`; slow input or scrolling on large pages. A GitLab issue (#580616) records laggy UIs on content-heavy pages and suspects `:has()`, but names no cause or timings, so treat it as a lead, not proof | Toggle a class from JavaScript, or anchor on a component |
| `:is()` with an id | `is-id-specificity`; a rule beats overrides everywhere | Attribute selector, or split the rule |
| Typo in a forgiving list | A member counts 0 in step 5; no console error | Count members alone; fix the name |
| `:has()` in a comma list on an older engine | `has-in-list` with `--legacy` | Separate rule, or accept the support line |
| `:has()` as a state store (checkbox hacks for themes or menus) | `body:has(input:checked)` driving a global change | Use real state, a `<button>` and `aria-expanded`, and `keyboard-focus.md` |

## Quality bar

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

1. `selector_lint.py` on all touched files prints `0 error`.
2. No `has-broad-anchor` warning remains, or the user approved it in writing.
3. Every selector changed has a `--spec` line before and after in the report.
4. Every `:is()` and `:where()` member that should match is counted above 0 by `match_count.py`.
5. No selector prints `INVALID`.
6. The report lists what was not tested, and no support claim lacks a source 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 | 95 | Always loaded |
| This file | 3,700 | The skill fires |
| `references/patterns.md` | 500 | A recipe is needed (quantity query, form state, previous sibling) |
| `references/appendix.md` | 589 | The user asks for measured values or source notes |
| Script output | 300 | Each run |
| Typical run | 4,095 | Description, this file, script output |
| Worst case | 6,584 | Everything above |

Cheaper path: run `selector_lint.py`, fix only flagged lines, skip both references.

## Tool-specific appendix

`match_count.py` uses Playwright for Python with Chromium. Other engines need a manual check in their DevTools. `selector_lint.py` is a text checker and does not parse every valid selector: unusual escapes and `@scope` are not modelled. When in doubt, confirm the weight with the browser's DevTools specificity tooltip.

## Sources

Last verified 30 October 2026.

- Anthropic, Skill authoring best practices: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
- MDN, `:has()`: https://developer.mozilla.org/en-US/docs/Web/CSS/:has
- WebKit, Using `:has()` as a CSS parent selector: https://webkit.org/blog/13096/css-has-pseudo-class/
- Chrome for Developers, `:has()`: the family selector: https://developer.chrome.com/blog/has-m105
- web.dev, `:is()` and `:where()`: https://web.dev/articles/css-is-and-where
- GitLab issue 580616, performance degradation on large pages: https://gitlab.com/gitlab-org/gitlab/-/issues/580616

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/modern-css-selectors/SKILL.md
