Advanced Selectors: :has(), :is(), :where()

Reviewed & published by Brayan K

Modern CSS pseudo-classes let you match elements by context and condense selector lists: :has() is the long-awaited "parent" selector, :is() groups selectors while keeping their specificity, and :where() does the same but always with zero specificity.

Part of the free HTML & CSS course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

💡 Think of It Like This

Think of CSS selectors like smart filters on a photo app. Basic selectors are like picking "all portraits." :has() is like saying "show me all albums that contain a sunset photo." :is() bundles multiple filters into one button. :where() does the same but lets any future filter override it easily — like a suggestion rather than a rule.

Understanding Advanced CSS Selectors

For most of CSS history, selectors could only look down the DOM tree — you could style a child based on its parent, but never the other way around. The introduction of :has() in 2023 changed everything. Now you can style a parent based on what it contains, unlocking patterns that previously required JavaScript.

Meanwhile, :is() and :where() solve a different problem: selector repetition. Instead of writing the same rule for article h1, article h2, and article h3, you group them into a single, readable selector. The difference between :is() and :where() is subtle but critical — it's all about specificity, which determines which rule wins when multiple rules conflict.

Finally, :not() and :nth-child() round out the toolkit. Together, these five pseudo-classes let you express almost any selection logic in pure CSS, reducing your reliance on extra classes and JavaScript DOM manipulation.

Selector Comparison

SelectorPurposeSpecificityBrowser Support
:has(sel)Style parent based on childSame as argumentDec 2023+
:is(a, b)Group selectorsHighest in list2020+
:where(a, b)Group selectors (overridable)Always 02020+
:not(sel)Exclude matching elementsSame as argumentAll browsers
:nth-child(An+B)Pattern-based position0-1-0All browsers

Worked Example: Proving the Specificity Difference

The table above says :is() scores as the highest selector in its list and :where() always scores zero. That sentence is easy to read and easy to forget. The example below makes it visible: three elements, and the colour each one ends up with tells you which rule won.

A quick reminder of how specificity is counted, written as ids-classes-types: an id is 1-0-0, a class or pseudo-class is 0-1-0, and a plain tag name is 0-0-1. Higher wins, and only if two selectors tie does the later rule in the file win.

<!DOCTYPE html>
<html>
<head>
  <title>:is() vs :where() specificity</title>
  <style>
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e5e7eb; padding: 24px; line-height: 1.6; }
    h1 { font-size: 1.4rem; }
    .label { font-size: 12px; color: #94a3b8; font-family: monospace; }

    /* ---------- GROUP A: :where() contributes ZERO specificity ---------- */

    /* Normally a class (0-1-0) beats a tag (0-0-1). But everything inside
       :where() is worth NOTHING, so this rule scores 0-0-0. */
    :where(.a) { color: #ef4444; }   /* red   - specificity 0-0-0 */

    /* A plain tag selector is 0-0-1, which is more than nothing, so it wins
       even though it is written FIRST and is the "weaker looking" selector. */
    h2 { color: #22c55e; }           /* green - specificity 0-0-1  ← WINS */


    /* ---------- GROUP B: :is() takes the HIGHEST specificity in its list ---------- */

    /* This list contains .b (a class) and p (a tag). :is() scores the whole
       thing at the highest of them: 0-1-0. That beats a plain p. */
    :is(.b, p) { color: #ef4444; }   /* red   - specificity 0-1-0  ← WINS */

    p { color: #22c55e; }            /* green - specificity 0-0-1 */

    /* The trap: the SECOND paragraph has no class at all. It matches through
       the "p" branch of the list - but the rule still scores 0-1-0, because
       :is() is priced on its most expensive argument, not on the branch that
       actually matched. So it goes red too. */
  </style>
</head>
<body>
  <h1>Which colour wins?</h1>

  <h2 class="a">A. Green, because :where(.a) is worth zero</h2>
  <p class="label">:where(.a) vs h2</p>

  <p class="b">B1. Red, because :is(.b, p) scores as a class</p>
  <p class="label">:is(.b, p) vs p</p>

  <p>B2. Also red - and this one surprises people</p>
  <p class="label">no class at all, yet still 0-1-0</p>
  <!-- ✅ Expected result, measured in a real browser:
     h1 -> font-size: 22.4px
     .label -> color: rgb(239, 68, 68)
     .label -> count: 3
  -->
</body>
</html>
The page this code makes: Fully commented — the colour of each line tells you which rule won
What this code shows in a browser window 720 pixels wide.

The practical takeaway: put your resets and defaults inside :where() so they are trivially easy to override later, and keep :is() for grouping rules you actually want to be hard to override.

:has() — The Parent Selector

For years, CSS couldn't style a parent based on its children. :has() finally solves this. It selects elements that contain a matching descendant.

The first rule says: "Any .card that contains an <img> should get a blue border." The second highlights a form when any input inside it is invalid — something that previously required JavaScript event listeners.

<!DOCTYPE html>
<html>
<head>
    <style>
        body { font-family: Arial, sans-serif; padding: 20px; }
        .card { border: 2px solid #ccc; padding: 16px; margin: 12px 0; border-radius: 8px; }
        .card:has(img) {
            border-color: #2196F3;
            background: #E3F2FD;
        }
        .card:has(.urgent) {
            border-color: #F44336;
            background: #FFEBEE;
        }
        .card img { width: 100%; max-width: 200px; border-radius: 4px; }
        .urgent { color: #D32F2F; font-weight: bold; }
        h2 { margin-top: 0; }
    </style>
</head>
<body>
    <h1>:has() — The Parent Selector</h1>
    <p>Cards with images get blue borders. Cards with <code>.urgent</code> get red borders.</p>

    <div class="card">
        <h2>Plain Card</h2>
        <p>No image, no urgency — default grey border.</p>
    </div>

    <div class="card">
        <h2>Card with Image</h2>
        <img src="https://picsum.photos/200/120" alt="Sample placeholder thumbnail used in CSS selector demo">
        <p>This card contains an image, so :has(img) applies.</p>
    </div>

    <div class="card">
        <h2>Urgent Card</h2>
        <p class="urgent">⚠ Action required immediately!</p>
    </div>
  <!-- ✅ Expected result, measured in a real browser:
     .card -> padding-top: 16px
     .card:has(img) -> background-color: rgb(227, 242, 253)
     .card -> count: 3
  -->
</body>
</html>

:is() vs :where() — Grouping with Different Specificity

Both :is() and :where() group selectors to reduce repetition, but they differ in one critical way — specificity:

When to use which? Use :is() when you want your grouped rule to have normal specificity. Use :where() when building base/utility styles that should be easily overridable by any more specific rule — it's perfect for CSS resets and default styles.

<!DOCTYPE html>
<html>
<head>
    <style>
        body { font-family: Arial, sans-serif; padding: 20px; }
        :is(h1, h2, h3) {
            color: #1565C0;
            border-bottom: 2px solid #BBDEFB;
            padding-bottom: 4px;
        }
        :where(article, section, aside) p {
            line-height: 1.8;
            color: #555;
        }
        .highlight p {
            color: #E65100;
            font-weight: bold;
        }
        .demo-box {
            border: 1px solid #ddd;
            padding: 16px;
            margin: 12px 0;
            border-radius: 6px;
        }
        .nav :is(a, button) {
            padding: 8px 16px;
            margin: 4px;
            border: none;
            background: #1976D2;
            color: white;
            border-radius: 4px;
            cursor: pointer;
            text-decoration: none;
            display: inline-block;
        }
        .nav :is(a, button):hover {
            background: #1565C0;
        }
    </style>
</head>
<body>
    <h1>:is() vs :where()</h1>

    <div class="demo-box">
        <h2>:is() applies styles with full specificity</h2>
        <h3>All headings get blue + border</h3>
    </div>

    <section class="demo-box">
        <h2>:where() — zero specificity</h2>
        <p>This paragraph has relaxed line-height from :where().</p>
    </section>

    <section class="demo-box highlight">
        <h2>Override :where() easily</h2>
        <p>This paragraph overrides :where() with just a class selector.</p>
    </section>

    <div class="nav demo-box">
        <h2>:is() for navigation</h2>
        <a href="#">Link</a>
        <button>Button</button>
        <a href="#">Another</a>
    </div>
  <!-- ✅ Expected result, measured in a real browser:
     .highlight p -> color: rgb(230, 81, 0)
     .demo-box -> padding-top: 16px
     .demo-box -> count: 4
  -->
</body>
</html>
The page this code makes: Try :is() vs :where()
What this code shows in a browser window 720 pixels wide.

:not() and :nth-child() — Exclusion & Patterns

:not() excludes elements from a selection. It's incredibly useful for styling "everything except" a specific class or state. Meanwhile, :nth-child(An+B) lets you select elements by their position using a formula.

FormulaSelectsExample
2nEvery even item2, 4, 6, 8...
2n+1Every odd item1, 3, 5, 7...
3nEvery 3rd item3, 6, 9, 12...
-n+3First 3 items only1, 2, 3
n+44th item and beyond4, 5, 6, 7...
<!DOCTYPE html>
<html>
<head>
    <style>
        body { font-family: Arial, sans-serif; padding: 20px; }
        ul { list-style: none; padding: 0; max-width: 400px; }
        li {
            padding: 10px 16px;
            margin: 4px 0;
            border-radius: 6px;
            background: #f5f5f5;
        }
        li:not(.disabled) {
            cursor: pointer;
            background: #E8F5E9;
        }
        li:not(.disabled):hover {
            background: #C8E6C9;
        }
        .disabled {
            opacity: 0.5;
            text-decoration: line-through;
        }
        .grid {
            display: grid;
            grid-template-columns: repeat(4, 1fr);
            gap: 8px;
            max-width: 500px;
        }
        .grid div {
            padding: 20px;
            text-align: center;
            border-radius: 6px;
            background: #E3F2FD;
            font-weight: bold;
        }
        .grid div:nth-child(3n) { background: #FFF3E0; color: #E65100; }
        .grid div:nth-child(-n+3) { background: #F3E5F5; color: #7B1FA2; }
    </style>
</head>
<body>
    <h1>:not() and :nth-child() Formulas</h1>

    <h2>:not(.disabled)</h2>
    <ul>
        <li>✅ Available item</li>
        <li class="disabled">❌ Disabled item</li>
        <li>✅ Another available</li>
        <li class="disabled">❌ Also disabled</li>
        <li>✅ Last available</li>
    </ul>

    <h2>:nth-child() formulas</h2>
    <p>Purple = first 3 items (<code>-n+3</code>), Orange = every 3rd (<code>3n</code>)</p>
    <div class="grid">
        <div>1</div><div>2</div><div>3</div><div>4</div>
        <div>5</div><div>6</div><div>7</div><div>8</div>
        <div>9</div><div>10</div><div>11</div><div>12</div>
    </div>
  <!-- ✅ Expected result, measured in a real browser:
     ul -> list-style-type: none
     li:not(.disabled) -> cursor: pointer
     .disabled -> opacity: 0.5
     .grid -> display: grid
     .disabled -> count: 2
  -->
</body>
</html>
The page this code makes: Try :not() and :nth-child() Formulas
What this code shows in a browser window 720 pixels wide.

Combining Selectors — Real-World Patterns

The real power of advanced selectors emerges when you combine them. For example, .form-group:has(:invalid:not(:placeholder-shown)) highlights a form field container only when the user has typed invalid input — not when the field is empty. This creates a polished validation UX with zero JavaScript.

Similarly, combining :is() with :not() lets you target active/inactive states cleanly, and mixing :not() with :nth-child() enables patterns like "style every odd incomplete task differently."

<!DOCTYPE html>
<html>
<head>
    <style>
        body { font-family: system-ui, sans-serif; padding: 20px; }

        /* Form validation with :has() + :invalid */
        .form-group {
            margin: 12px 0;
            padding: 12px;
            border: 2px solid #e0e0e0;
            border-radius: 8px;
            transition: border-color 0.3s, background 0.3s;
        }
        .form-group:has(:invalid:not(:placeholder-shown)) {
            border-color: #F44336;
            background: #FFEBEE;
        }
        .form-group:has(:valid:not(:placeholder-shown)) {
            border-color: #4CAF50;
            background: #E8F5E9;
        }

        label { display: block; font-weight: 600; margin-bottom: 4px; }
        input {
            width: 100%;
            padding: 8px 12px;
            border: 1px solid #ccc;
            border-radius: 4px;
            font-size: 1rem;
            box-sizing: border-box;
        }

        /* :is() + :not() combined */
        .nav-list { list-style: none; padding: 0; display: flex; gap: 4px; }
        .nav-list li {
            padding: 8px 16px;
            border-radius: 6px;
            cursor: pointer;
            background: #E3F2FD;
        }
        .nav-list li:is(.active) {
            background: #1976D2;
            color: white;
            font-weight: bold;
        }
        .nav-list li:not(.active):hover {
            background: #BBDEFB;
        }

        /* :nth-child(of S) — new in CSS Selectors 4 */
        .task-list { list-style: none; padding: 0; max-width: 400px; }
        .task-list li {
            padding: 10px 16px;
            margin: 4px 0;
            border-radius: 6px;
            background: #f5f5f5;
        }
        .task-list li.done { text-decoration: line-through; opacity: 0.6; }
        .task-list li:not(.done):nth-child(odd) { background: #E8F5E9; }

        h2 { color: #1565C0; margin-top: 24px; }
        .note { color: #666; font-size: 0.9rem; }
    </style>
</head>
<body>
    <h1>Combining Advanced Selectors</h1>

    <h2>Form Validation with :has() + :invalid</h2>
    <p class="note">Type in the fields — the container border changes based on validity.</p>
    <div class="form-group">
        <label>Email</label>
        <input type="email" placeholder="Enter email" required>
    </div>
    <div class="form-group">
        <label>URL</label>
        <input type="url" placeholder="https://example.com">
    </div>

    <h2>:is() + :not() — Active Tab</h2>
    <ul class="nav-list">
        <li class="active">Dashboard</li>
        <li>Projects</li>
        <li>Settings</li>
        <li>Help</li>
    </ul>

    <h2>Mixed :not() + :nth-child()</h2>
    <ul class="task-list">
        <li>📝 Design homepage</li>
        <li class="done">✅ Set up repo</li>
        <li>📝 Write tests</li>
        <li class="done">✅ Deploy staging</li>
        <li>📝 Code review</li>
    </ul>
  <!-- ✅ Expected result, measured in a real browser:
     label -> display: block
     input -> box-sizing: border-box
     .nav-list -> display: flex
     .nav-list li -> cursor: pointer
     .form-group -> count: 2
  -->
</body>
</html>
The page this code makes: Combining Selectors: Validation, Tabs, & Mixed Patterns
What this code shows in a browser window 720 pixels wide.

🎯 Your Turn: A Task List With No JavaScript

Three blanks, one for each selector this lesson has taught you. The list is fully built — you are only writing the pseudo-classes. Everything you are about to make happen, including the live tick-to-strike-through, happens with no script of any kind.

<!DOCTYPE html>
<html>
<head>
  <title>Your Turn: Task List</title>
  <style>
    * { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e5e7eb; padding: 32px; }

    ul {
      list-style: none;
      margin: 0;
      padding: 0;
      max-width: 360px;
      border: 1px solid #334155;
      border-radius: 10px;
      overflow: hidden;
    }
    .task {
      display: flex;
      align-items: center;
      gap: 10px;
      padding: 12px 14px;
      font-size: 14px;
    }

    /* 🎯 YOUR TURN — replace each ___ below. Everything else already works. */

    /* 👉 1) Fade and strike through any row that CONTAINS a ticked checkbox.
           This is the "style the parent from the child" pseudo-class. */
    .task:___(input:checked) {
      opacity: .5;
      text-decoration: line-through;
    }

    /* 👉 2) Put a divider under every row EXCEPT the last one, so the list
           does not end with a stray line. Which pseudo-class excludes? */
    .task:___(:last-child) {
      border-bottom: 1px solid #334155;
    }

    /* 👉 3) Zebra-stripe the list: shade rows 2, 4, 6... Use an nth-child
           formula, not four separate rules. */
    .task:nth-child(___) {
      background: #1e293b;
    }

    /* ✅ Expected result, measured in Chrome:
       - Rows 2 and 4 (the ticked ones) report opacity 0.5 and
         text-decoration-line: line-through. Rows 1 and 3 report opacity 1
         and none.
       - Rows 2 and 4 also report background rgb(30, 41, 59); rows 1 and 3
         report rgba(0, 0, 0, 0) - fully transparent.
       - Rows 1, 2 and 3 have border-bottom-width 1px. Row 4 has 0px.
       - Tick and untick a box while it runs. The strike-through should
         appear and disappear live, with no JavaScript anywhere. */
  </style>
</head>
<body>
  <h1>Today</h1>

  <ul>
    <li class="task"><input type="checkbox"> Write the outline</li>
    <li class="task"><input type="checkbox" checked> Buy milk</li>
    <li class="task"><input type="checkbox"> Call the plumber</li>
    <li class="task"><input type="checkbox" checked> Book tickets</li>
  </ul>
</body>
</html>
.task:has(input:checked)   { ... }
.task:not(:last-child)     { ... }
.task:nth-child(2n)        { ... }

Note that blank 3 could also be written :nth-child(even) — the keyword and the 2n formula select exactly the same rows. Reach for the formula when you need something the keywords cannot express, like 3n+1 or -n+3.

🏆 Mini-Challenge: Validation That Waits Its Turn

No blanks and no answers. Build live form validation in CSS alone. The easy half is turning an invalid field red. The half that separates a polished form from an annoying one is not shouting at someone who has not typed anything yet — and that is what stacking :has(), :invalid and :not(:placeholder-shown) into a single selector buys you.

:placeholder-shown matches an input that is currently displaying its placeholder text — in other words, an empty one. So :not(:placeholder-shown) reads as "the user has typed something". It only works on inputs that actually have a placeholder attribute, which is why the markup below has one on every field.

<!DOCTYPE html>
<html>
<head>
  <title>Mini-Challenge: Live Form Validation</title>
  <style>
    * { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e5e7eb; padding: 32px; }
    form { max-width: 340px; }
    label { display: block; font-size: 13px; margin-bottom: 6px; }
    .field input {
      width: 100%;
      padding: 8px;
      background: #0f172a;
      color: #e5e7eb;
      border: 1px solid #475569;
      border-radius: 6px;
    }

    /* 🎯 MINI-CHALLENGE — write the rules yourself. No answers below.

       Build live form validation with zero JavaScript. The hard part is
       NOT turning a bad field red - it is not shouting at someone who has
       not typed anything yet.

       1. .field -> 2px solid #334155 border, border-radius 8px,
                    padding 12px, margin-bottom 12px

       2. .field .msg -> display: none; colour #f87171; font-size 12px

       3. The "typed something wrong" state. You need THREE pseudo-classes
          stacked in one selector:
            - :has(...)              to style the .field from its input
            - :invalid               the browser's own validity check
            - :not(:placeholder-shown)  "the box is not empty"
          Give that selector border-color #ef4444, and write a second
          selector for the same condition that sets .msg to display: block.

       4. The "typed something right" state: the same shape, but :valid
          instead of :invalid, with border-color #22c55e.

       ✅ Check yourself (measured behaviour of the correct answer):
          - Before you type: the border stays rgb(51, 65, 85) and the
            message is display: none. No red on an untouched form.
          - Type "nope": the border becomes rgb(239, 68, 68) and the
            message becomes display: block.
          - Clear it and type "[email protected]": the border becomes
            rgb(34, 197, 94) and the message goes back to none.
          - Delete :not(:placeholder-shown) and reload. The field is red
            before anyone has touched it - that is exactly the bug that
            pseudo-class exists to prevent.
          - The placeholder attribute in the HTML is load-bearing:
            :placeholder-shown only matches an input that HAS one.
    */
  </style>
</head>
<body>
  <form>
    <div class="field">
      <label for="email">Email address</label>
      <input id="email" type="email" placeholder="[email protected]" required>
      <div class="msg">That does not look like an email address.</div>
    </div>

    <div class="field">
      <label for="site">Website</label>
      <input id="site" type="url" placeholder="https://example.com">
      <div class="msg">A URL needs to start with https://</div>
    </div>
  </form>
  <!-- ✅ Expected result, measured in a real browser:
     label -> display: block
     form -> max-width: 340px
     .field -> count: 2
  -->
</body>
</html>

When to Use These Selectors

⚠️ Common Mistakes

🎉 Lesson Complete

Practice quiz

What does the :has() pseudo-class let you do that wasn't possible before?

  • Animate any property
  • Style a parent based on what it contains
  • Select elements by their text content
  • Override specificity entirely

Answer: Style a parent based on what it contains. :has() is the long-awaited parent selector — it matches an element that contains a descendant matching its argument.

What does .card:has(img) select?

  • Every img inside a .card
  • Any .card that contains an img
  • The first img on the page
  • Cards that are images

Answer: Any .card that contains an img. :has() styles the parent: any .card containing an img matches and gets the rule.

How does :is() affect specificity?

  • It always contributes zero specificity
  • It takes the specificity of the most specific selector in its list
  • It doubles the specificity
  • It removes specificity from neighbouring selectors

Answer: It takes the specificity of the most specific selector in its list. :is() groups selectors and takes the highest specificity from its argument list.

What is the specificity of :where()?

  • The highest in its list
  • The lowest in its list
  • Always zero
  • The same as an ID selector

Answer: Always zero. :where() always contributes zero specificity, making its rules easy to override — ideal for resets and base styles.

When should you reach for :where() over :is()?

  • When you want the rule to be hard to override
  • When building base/utility styles that should be easily overridable
  • When targeting IDs only
  • When you need higher specificity

Answer: When building base/utility styles that should be easily overridable. Use :where() for overridable defaults (resets, base styles); use :is() when you want normal specificity.

What does the :not() pseudo-class do?

  • Selects the negation — elements that do NOT match its argument
  • Disables an element
  • Inverts the colours
  • Hides an element

Answer: Selects the negation — elements that do NOT match its argument. :not(sel) excludes elements matching sel, letting you style 'everything except' a class or state.

Which :nth-child() formula selects only the first three items?

  • 3n
  • 2n+1
  • -n+3
  • n+3

Answer: -n+3. -n+3 matches positions 1, 2 and 3 — the first three items.

Which :nth-child() formula selects every third item (3, 6, 9, ...)?

  • 3n
  • n+3
  • -n+3
  • 3n+1

Answer: 3n. 3n matches every third element: 3, 6, 9, 12, and so on.

Browsers explicitly forbid which of these :has() usages?

  • .card:has(img)
  • Nesting :has() inside :has(), e.g. :has(:has(...))
  • .form:has(:invalid)
  • :has() combined with :not()

Answer: Nesting :has() inside :has(), e.g. :has(:has(...)). :has() may not be nested inside another :has(). Keep it at a single level.

If a list mixes element types and you want to count only one tag, which should you use instead of :nth-child()?

  • :nth-of-type()
  • :first-child
  • :only-child
  • :nth-last-child()

Answer: :nth-of-type(). :nth-child() counts all siblings; :nth-of-type() counts only siblings of the matching element type.

Continue this course