CSS and Layout skill 6 of 12

Modern CSS Selectors SKILL.md: Fix :has(), :is() and :where() Bugs

The Modern CSS Selectors skill makes Claude, Cursor or another AI agent audit :has(), :is(), :where() and :not() rules for slow anchors, invalid syntax and specificity that changes by accident. A linter and a specificity calculator run first, then Chromium counts matches, so each rewrite is proved, not assumed.

modern-css-selectors/SKILL.md, version 1.0.0, 2,557 words, plus scripts/selector_lint.py, scripts/match_count.py and two reference files.

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.

RequestUse instead
Which layer wins, @layer order, !important in layerscascade-layers.md
Nested rules, &, declarations after a nested rulecss-nesting.md
Style by container widthcontainer-queries.md
Choosing the right element, heading or landmarksemantic-html.md
Form validity styling and error texthtml-forms.md
Selector engines in JavaScript, querySelector performanceOut 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.

InputAsk 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?"

Raw SKILL.md file

.md

This is the exact file agents read, including the credit header and licence notice. Save it as modern-css-selectors/SKILL.md.

---
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

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().

CodeLevelMeaningFix
has-nestederror:has() inside :has() is invalidStep 3
has-pseudo-elementerrorA pseudo-element inside :has() is invalidStep 3
has-visitederror:visited inside :has() never matchesStep 3
has-broad-anchorwarnAnchor is html, body, :root, * or emptyStep 3
has-in-listwarn (--legacy)An unsupported :has() drops the whole listStep 3
is-id-specificity, not-id-specificitywarnAn id inside the function sets the weight of the whole selectorStep 4
is-uneven-listinfoMembers differ in specificity; the heaviest applies to allStep 4
has-descendantinfoUnbounded descendant testStep 3, only if the relation is direct
legacy-anyinfo:matches() or a vendor aliasReplace 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

NeedUseRule
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 JavaScriptDo 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:

CheckPass
Each member of every new :is() or :where() list, counted aloneEvery member that should match something returns above 0
New selector against the old oneSame count, or a difference you can name
INVALID in the outputA selector the browser rejects. The script exits 1. Fix before continuing
--spec before and afterEqual, 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:

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:

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

FailureHow to spot itDo instead
:has() anchored on body, html or * for page statehas-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 proofToggle a class from JavaScript, or anchor on a component
:is() with an idis-id-specificity; a rule beats overrides everywhereAttribute selector, or split the rule
Typo in a forgiving listA member counts 0 in step 5; no console errorCount members alone; fix the name
:has() in a comma list on an older enginehas-in-list with --legacySeparate rule, or accept the support line
:has() as a state store (checkbox hacks for themes or menus)body:has(input:checked) driving a global changeUse 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.

ItemTokensRead when
Description95Always loaded
This file3,700The skill fires
references/patterns.md500A recipe is needed (quantity query, form state, previous sibling)
references/appendix.md589The user asks for measured values or source notes
Script output300Each run
Typical run4,095Description, this file, script output
Worst case6,584Everything 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.

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

What this skill does

Selector bugs rarely throw errors. A :is() list with one id in it gives every element it matches the weight of an id, so the override you wrote next to it never wins. In Chromium 153, :is(.card, #promo) .title measured (1,1,0) on every card, and .card .title at (0,2,0) lost everywhere, not only on the promo.

The skill runs scripts/selector_lint.py over your CSS and any <style> blocks. It flags :has() inside :has(), pseudo-elements and :visited inside :has(), anchors on body, html, :root or *, ids inside :is() and :not(), and uneven :is() lists. MDN advises against broad anchors such as body:has() and prefers .container:has(> .foo), so the skill treats them as a warning and proposes a component anchor or a class toggled from JavaScript.

Then it prints the specificity of every selector it plans to change, before and after, with --spec. After the rewrite, scripts/match_count.py opens a page you own in Chromium and counts what each selector matches. Because :is() and :where() ignore invalid members without a warning, a typo such as .crad matches nothing and says nothing. Counting each member alone is the only way to see it.

Who it is for

Use it if you write or review CSS, build themes, maintain a design system, or check styles that an AI tool generated. It fits when a parent must react to a child, when a selector list has grown long, when an override loses to a rule it should beat, or when a :has() rule is slow or ignored.

What is inside

The file is 2,557 words (version 1.0.0), with two scripts and two reference files. It holds:

  • Six guardrails, including bulk edits shown as a diff first, no :has() on html or body without a written reason, and no support claim from memory.
  • Six inputs the agent must have first, with the exact question for each, including the browser list.
  • A workflow of steps 0 to 6, with a table that maps each linter code to a fix and a decision table for choosing between :has(), :is(), :where() and :not().
  • scripts/selector_lint.py, a standard-library linter and specificity calculator, and scripts/match_count.py, which prints counts and selectors but no page text.
  • A worked example with real output, five failure modes and an eight-item quality bar.
  • references/patterns.md with recipes for captions, previous siblings, quantity queries and form state, each run in Chromium 153, and references/appendix.md with measured behaviour and source notes.

The token costs below are estimates from the skill’s own count: words times 1.35 for prose and characters divided by 3.5 for code and tables. They are not from a model tokeniser.

ItemTokensStatus
Description (always loaded)95Estimate
SKILL.md body (when the skill fires)3,700Estimate
references/patterns.md, read only when a recipe is needed500Estimate
references/appendix.md, read only for measured values or source notes589Estimate
Linter and match count output, typical run300Estimate
Typical run (description, body and script output)4,095Estimate
Cheaper path: same as the typical run, which already skips every reference4,095Estimate
Worst case (both references and 40 lines of linter output)6,584Estimate

How to install and use it in Claude, Cursor and other agents

Claude. Keep the folder as modern-css-selectors/ with SKILL.md, scripts/ and references/ inside, and zip it. In Claude, open Customize, then Skills, then Upload a skill. In Claude Code, put the folder in ~/.claude/skills/, or in .claude/skills/ inside a project.

Cursor and other agents. Put the folder where your tool reads skills, or paste the body into its rules and keep the scripts beside your code. The linter needs Python 3 only. The match count needs Playwright for Python with Chromium. Then send the starting prompt from the Templates section of the skill.

Before you install. Read the scripts first. The linter reads files and writes nothing. The match count opens a page you own and prints counts and selectors. The skill tells the agent to edit only in batches you approve.

A worked example

An invented product page has four rules: body:has(.cart-open) locking scroll, a :has(.badge) colour rule, an :is(.card, #promo) .title size rule and a plain .card .title rule that never applied. The linter reports 0 errors, 2 warnings and 1 info line. The two warnings are the body anchor and the id inside :is().

--spec shows the cause: the :is() rule weighs (1,1,0) and .card .title weighs (0,2,0). One approved batch moves the scroll lock to a cart-open class on <html>, narrows the badge rule to .card:has(> .badge) and swaps #promo for [id="promo"], which brings the :is() rule to (0,2,0) so source order decides. The linter then prints 0 across the board, and the counts show 1 badge match, 2 cards and 1 promo.

Common mistakes

The skill is built around five ways this goes wrong:

  • Anchoring :has() on body or html. The browser re-checks the whole page when anything inside it changes. The skill anchors on a component or toggles a class from JavaScript.
  • Putting an id inside :is() or :not(). It sets the weight of the whole selector. The skill uses an attribute selector or splits the rule.
  • Trusting a forgiving list. A typo inside :is() or :where() is ignored silently. The skill counts every member alone.
  • Listing :has() with other selectors for older engines. An engine that cannot parse it drops the whole rule. The skill uses a separate rule when the browser list needs it.
  • Using :has() as a state store. A checkbox hack driving a global theme or menu skips keyboard and focus handling. The skill uses real state and a keyboard check.

Cascade Layers decides which layer wins when specificity is equal or irrelevant. CSS Nesting covers the & selector and the specificity of nested lists. Container Queries replaces many viewport-based quantity tricks with component-based rules.

Frequently asked questions

MDN lists :has() as widely available, supported across browsers since December 2023. If your analytics show engines older than that, the skill adds a fallback in a separate rule, because a browser that cannot parse :has() drops any rule that contains it. It asks for your browser list before it decides, and marks support claims to verify.

MDN advises against anchors such as body:has(), :root:has() and *:has(), because the browser must re-check the subtree when it changes, and recommends .container:has(> .foo). The linter flags broad anchors as a warning. It gives no timings, because none were measured here: tell the agent to profile your own page.

:is() takes the specificity of its most specific argument, so :is(.a, #b) weighs (1,0,0). :where() always weighs zero, so any later rule can override it. Use :where() for resets and library defaults, and :is() to group repeated selectors. The --spec option prints both so you can compare.

No. :has() cannot contain :has() or a pseudo-element, and :visited inside it never matches, according to Chrome for Developers. Chain :has(a):has(b) for AND and use :has(a, b) for OR. The linter reports all three mistakes as errors.

Probably an id in the list. Chromium 153 measured :is(.card, #promo) .title at (1,1,0) for every card, not only the promo. Swap the id for [id="promo"] or split the rule. The skill prints the weight before and after each change, so a rewrite never shifts it by accident.

No, and that is the danger. :is() and :where() are forgiving, so an invalid or misspelt member is ignored and the rest still match. The skill counts each member alone with match_count.py.

The description is about 95 tokens and loads in every session. The SKILL.md body is about 3,700 tokens when the skill fires. A typical run is about 4,095 tokens with script output, and the worst case with both references is about 6,584. All figures are estimates, not from a model tokeniser.

Changelog

  • 1.0.0, 30 October 2026: first release.

Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.