Mert Özen Further With Every Line
Frontend CSS3: Flexbox, Grid and Modern CSS — #02

Modern Selectors: :is(), :where(), :has(), :not() and State Pseudo-classes

Mert Özen Nov 14, 2026 13 min 2 views
Modern Selectors: :is(), :where(), :has(), :not() and State Pseudo-classes

:is() takes its specificity from its most specific argument, while :where() is always zero; :not() and :has() are calculated like :is(). On Bookstore's catalog and checkout pages we measure specificity through the DevTools protocol, style a book card without a cover with :has() and an invalid form row with :user-invalid without JavaScript, and show the real behavior of :focus-visible, :open, :nth-child(of S) and :empty in Chromium and WebKit.

Version note: This post follows the Selectors Level 4 specification and the state of Baseline in November 2026; the examples were run in Chrome 158 and WebKit 27.2 via Playwright 1.65, and Firefox support is taken from web-features data.

Guess First

In the catalog of Bookstore, the online bookstore used as the shared example across this site's series, six different rules in the same layer give a color to the price on a book card. The card is an li.book-card with a cover image and a p.price inside; there is no element with the id #featured or #promo on the page, and the price has no .sale class:

@layer components {
    .book-card .price { color: #1d4ed8; }
    li.book-card .price { color: #0f766e; }
    .book-card:has(> img) .price { color: #7c3aed; }
    :is(#featured, .book-card) .price { color: #b91c1c; }
    :where(#featured, .book-card) .price { color: #ca8a04; }
    .price:not(.sale, #promo) { color: #be185d; }
}

What color is the price? Hint: the answer depends on whether an id counts toward specificity for an element that has no id at all.

In the previous post we saw how the cascade picks the winning value and that layers come before specificity. Inside a single layer, though, the decision still falls to specificity, and modern selectors change that calculation. In this post we try :is(), :where(), :not() and :has(), the state pseudo-classes that look at real user interaction, and a few structural selectors on Bookstore's catalog and checkout pages.

:is() and :where(): You Choose the Specificity

Both pseudo-classes do the same job: they match if any selector in the list inside them matches. :is(h2, h3) a is a short way of writing h2 a, h3 a. The only difference between them is specificity. The specificity of :is() equals that of its most specific argument, whether or not that argument matches the element. The specificity of :where() is always zero. I measured it with the helper from the previous post, which asks Chromium through the protocol DevTools uses:

node scripts/02-selectors.mjs
--- specificity
computed color: rgb(190, 24, 93)
✓ components         .price:not(.sale, #promo)            (1,1,0)  #be185d                02-specificity.html
✗ components         :is(#featured, .book-card) .price    (1,1,0)  #b91c1c                02-specificity.html
✗ components         .book-card:has(> img) .price         (0,2,1)  #7c3aed                02-specificity.html
✗ components         li.book-card .price                  (0,2,1)  #0f766e                02-specificity.html
✗ components         .book-card .price                    (0,2,0)  #1d4ed8                02-specificity.html
✗ components         :where(#featured, .book-card) .price (0,1,0)  #ca8a04                02-specificity.html
--- chromium
Dune        bg=rgb(255, 255, 255)  border=rgb(229, 231, 235)  title::after=" · e-book"
Middlemarch bg=rgb(254, 243, 199)  border=rgb(229, 231, 235)  title::after=none
Emma        bg=rgb(255, 255, 255)  border=rgb(147, 197, 253)  title::after=" · e-book"
The Hobbit  bg=rgb(254, 243, 199)  border=rgb(229, 231, 235)  title::after=none
cart-count display: none
cart-count with a space: inline
on load                invalid: true | user-invalid: false | label color: rgb(31, 41, 55)
focused                invalid: true | user-invalid: false | label color: rgb(29, 78, 216)
typed "ada@"           invalid: true | user-invalid: false | label color: rgb(29, 78, 216)
left the field         invalid: true | user-invalid: true | label color: rgb(185, 28, 28)
Apply, mouse click     focus: true | focus-visible: false
coupon, mouse click    focus: true | focus-visible: true
Apply, Tab key         focus: true | focus-visible: true
details :open: true | summary font-weight: 700
rules kept: :is(.price) | :where(.price)
color: rgb(0, 0, 0), font-weight: 700, font-style: italic, card border: 0px
--- webkit
Dune        bg=rgb(255, 255, 255)  border=rgb(229, 231, 235)  title::after=" · e-book"
Middlemarch bg=rgb(254, 243, 199)  border=rgb(229, 231, 235)  title::after=none
Emma        bg=rgb(255, 255, 255)  border=rgb(147, 197, 253)  title::after=" · e-book"
The Hobbit  bg=rgb(254, 243, 199)  border=rgb(229, 231, 235)  title::after=none
cart-count display: none
cart-count with a space: inline
on load                invalid: true | user-invalid: false | label color: rgb(31, 41, 55)
focused                invalid: true | user-invalid: false | label color: rgb(29, 78, 216)
typed "ada@"           invalid: true | user-invalid: false | label color: rgb(29, 78, 216)
left the field         invalid: true | user-invalid: true | label color: rgb(185, 28, 28)
details :open: true | summary font-weight: 700
rules kept: :is(.price, .price:-bookstore-unknown) | :where(.price, .price:-bookstore-unknown)
color: rgb(0, 0, 0), font-weight: 700, font-style: italic, card border: 0px

The first block of the output answers the opening question: the price is pink. .price:not(.sale, #promo) got (1,1,0), because :not() also counts its most specific argument, and that argument is an id. The fact that the element has no id changes nothing. :is(#featured, .book-card) .price got (1,1,0) for the same reason; the two tied, and :not(), which comes later in the source, won. The :where() version of the same list dropped to the bottom with (0,1,0): #featured counted as zero there, and the only thing counted was the .price outside.

This difference gives two separate uses. When you use :is() to shorten long selector lists, make sure no id gets into the list; otherwise every selector in it gains the weight of an id. :where() is ideal for writing "easy to override" defaults. I wrote Bookstore's focus style that way:

.cart-count:empty {
    display: none;
}

/* keyboard users get a strong ring, mouse users do not */
:where(a, button, input, summary):focus-visible {
    outline: 3px solid #1d4ed8;
    outline-offset: 2px;
}

The specificity of :where(a, button, input, summary):focus-visible is (0,1,0); only :focus-visible is counted. Any component that needs a different focus ring can override it with a single class. Using the same technique when writing a library makes life much easier for the people who use it.

:not() and Forgiving Selector Lists

With Selectors Level 4, :not() started to accept several arguments: .price:not(.sale, #promo) means "a price that is neither .sale nor #promo". Its specificity is calculated like that of :is(); as we just saw, an id written as an argument can make the rule unexpectedly strong.

Selector lists also differ in how they react to an invalid selector. In a plain list such as a, b, if one part is something the browser does not recognize, the whole rule is dropped. :is() and :where() are forgiving: they ignore the argument they do not recognize and work with the rest. :has() is deliberately not forgiving. I wrote four rules with a pseudo-class no browser knows:

.price, .price:-bookstore-unknown { color: #b91c1c; }
:is(.price, .price:-bookstore-unknown) { font-weight: 700; }
:where(.price, .price:-bookstore-unknown) { font-style: italic; }
.book-card:has(.price, :-bookstore-unknown) { border: 2px solid #b91c1c; }

The rules kept lines in the output show which rules each engine kept in the stylesheet. The plain list and the :has() rule were dropped completely in both: the price did not turn red and the card has no border. The :is() and :where() rules worked. There is a small difference too: Chromium removes the invalid argument when serializing, as in :is(.price), while WebKit keeps it as written; the result is the same in both. The practical value of this behavior: if you write an engine-specific, vendor-prefixed pseudo-class in the same list as other selectors, wrap it in :is(); otherwise the whole rule disappears in browsers that do not support that engine's prefix.

:has(): Selecting the Parent and the Previous Sibling

For years CSS had no way to select an element based on what it contains; a class was added with JavaScript instead. :has() has been Baseline since December 2023 and widely available since June 2026. The subject of the selector is still the outer element, but the condition is inside: .book-card:has(> img) means "a card that has an img as a direct child". Bookstore's catalog cards:

<ul class="book-list">
    <li class="book-card in-stock" data-format="hardcover ebook">
        <img src="img/dune.svg" alt="Cover of Dune" width="120" height="180">
        <h2 class="book-title">Dune</h2>
        <p class="book-author">Frank Herbert</p>
        <p class="price">349.00 TRY</p>
    </li>
    <li class="book-card" data-stock="0" data-format="paperback">
        <h2 class="book-title">Middlemarch</h2>
        <p class="book-author">George Eliot</p>
        <p class="price">289.00 TRY</p>
    </li>
    <li class="book-card in-stock" data-format="paperback ebook">
        <img src="img/emma.svg" alt="Cover of Emma" width="120" height="180">
        <h2 class="book-title">Emma</h2>
        <p class="book-author">Jane Austen</p>
        <p class="price">219.00 TRY</p>
    </li>
    <li class="book-card in-stock" data-format="hardcover">
        <h2 class="book-title">The Hobbit</h2>
        <p class="book-author">J. R. R. Tolkien</p>
        <p class="price">259.00 TRY</p>
    </li>
</ul>
.book-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

.book-card {
    padding: var(--space);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);
    background-color: var(--color-surface);
}

/* a card without a cover image gets a tinted background instead */
.book-card:not(:has(> img)) {
    background-color: #fef3c7;
}

/* zebra stripes count only the books that are in stock */
.book-card:nth-child(even of .in-stock) {
    border-color: #93c5fd;
}

.book-card[data-stock="0"] .price {
    text-decoration: line-through;
    color: var(--color-muted);
}

.book-card[data-format~="ebook"] .book-title::after {
    content: " · e-book";
    font-size: 0.75em;
    color: var(--color-muted);
}

In the catalog lines of the output, Middlemarch and The Hobbit have no cover; both got the rgb(254, 243, 199) background. I did not add a class to the HTML; the card's content decided. If a new book is added without a cover, or a cover image is removed, the style changes on its own.

The specificity of :has() also comes from its most specific argument. In the first block, .book-card:has(> img) .price got (0,2,1): two classes and one type from img. :has itself is not counted as a pseudo-class.

:has() is not only for selecting the parent. With a sibling combinator inside it you can select the previous sibling: h2:has(+ .badge) selects a heading immediately followed by a .badge. It is also very useful for form rows, which we look at in the next section.

State Pseudo-classes: :focus-visible, :focus-within, :user-invalid, :open

The checkout form has email, postcode and coupon fields:

<form class="checkout-form" action="/orders" method="post">
    <div class="form-row">
        <label for="email">Email</label>
        <input id="email" name="email" type="email" required>
    </div>
    <div class="form-row">
        <label for="postcode">Postcode</label>
        <input id="postcode" name="postcode" required pattern="[0-9]{5}">
    </div>
    <div class="form-row">
        <label for="coupon">Coupon code</label>
        <div class="input-group">
            <input id="coupon" name="coupon">
            <button class="btn" type="button">Apply</button>
        </div>
    </div>
    <details class="gift-options">
        <summary>Gift options</summary>
        <label><input type="checkbox" name="gift-wrap"> Gift wrap</label>
    </details>
    <button class="btn btn-primary" type="submit">Place order</button>
</form>
.form-row {
    margin-block-end: var(--space);
}

.form-row label {
    display: block;
    font-weight: 600;
}

/* highlight the whole row while the user is inside it */
.form-row:focus-within label {
    color: #1d4ed8;
}

/* only after the user has interacted with the field */
.form-row:has(> :user-invalid) label {
    color: var(--color-accent);
}

.form-row:has(> :user-invalid) input {
    border-color: var(--color-accent);
}

.gift-options:open > summary {
    font-weight: 700;
}

The classic mistake is to turn an invalid field red with :invalid. A required field is invalid as soon as the page opens, because it is empty; the form turns red before the user has typed anything. :user-invalid only matches after the user has interacted with the field. It has been Baseline since November 2023 and widely available since May 2026. The relevant part of the script:

// 3) Checkout: :user-invalid vs :invalid, :focus-within, :focus-visible, :open
({ browser, page } = await open('checkout.html', { engine }));
const email = page.locator('#email');
const state = async label => console.log(label.padEnd(22),
    'invalid:', await email.evaluate(e => e.matches(':invalid')),
    '| user-invalid:', await email.evaluate(e => e.matches(':user-invalid')),
    '| label color:', await style(page, 'label[for=email]', 'color'));
await state('on load');
await email.focus();
await state('focused');
await email.pressSequentially('ada@');
await state('typed "ada@"');
await page.locator('#postcode').focus();
await state('left the field');

There are four lines in the output, and they are the same in both engines. When the page opens, the field is :invalid but not :user-invalid, and the label has its normal color. When the field gets focus, .form-row:focus-within kicks in and the label turns blue; :focus-within matches while any element inside it has focus. Typing half an address does not trigger :user-invalid yet either. Only when the user leaves the field with an invalid value and moves to the postcode does :user-invalid match, and .form-row:has(> :user-invalid) label turns the label red. This is the behavior that shows the error at the right moment, and it took no JavaScript at all.

:focus-visible is the browser's answer to "should this focus be shown?". Look at the Chromium lines: after a mouse click on the "Apply" button, the button has focus but is not :focus-visible, because the user already knows where they are. After a mouse click in the coupon field, :focus-visible matched: in text fields the browser always shows the ring, because the caret marks where typing goes. Reaching the button with Tab matched again and the 3px ring was drawn. I took this measurement in Chromium only; platform behaviors such as WebKit on macOS not moving to buttons with Tab make the comparison meaningless. The accessibility reasoning behind focus rings is in the upcoming Accessibility (a11y) series.

The last line is :open: it matches elements such as details, dialog and a select with its picker open, while they are open. When "Gift options" opened, the summary became bold. It became Baseline newly available in May 2026; in browsers older than that, the details[open] attribute selector does the same job. The elements themselves are covered in the HTML5 and Semantic HTML series post on native interactive elements.

Structural Selectors: :nth-child(of S), :empty and Attribute Selectors

:nth-child(even) only looks at an element's position among its siblings. If the catalog contains a book that is out of stock, the zebra stripes shift. :nth-child(even of .in-stock) counts only the elements that are .in-stock. In the output, the book with the blue border is Emma: third in the list, but second among the books in stock. It has been Baseline since May 2023.

:empty selects an element with no children at all; I used it to hide the cart counter while it is empty, and in the output its display is none. But when I put a single space inside the counter, :empty stopped matching in both engines and the counter became inline. Selectors Level 4 suggests counting an element that contains only whitespace as :empty too, but according to MDN no browser implements this. A line break that slips into a server-side template silently breaks this selector.

Attribute selectors belong here as well: [data-stock="0"] is an exact match, while [data-format~="ebook"] looks for a word in a space-separated list. Dune and Emma, whose data-format contains ebook, got " · e-book" added to their titles with ::after. There are also ^= for "starts with", $= for "ends with", *= for "contains" and the i flag, which makes the match case-insensitive: a[href$=".pdf" i].

Selector Performance: Myths and Facts

Rules like "long selectors are slow" or "don't use *" date from years when browsers were much slower. A browser matches a selector from right to left: it first finds the elements that match the rightmost part, the key selector, and then checks the conditions moving left. Today's engines add hash tables and caches on top of that; on an ordinary page selectors have no measurable cost. Choose selectors for readability and specificity, not for performance.

What needs attention are pages where the DOM changes often and style calculation takes long. :has() is a special case here, because any change inside an element can change whether the outer element matches. A broad subject such as body:has(.toast) or :root:has(...) combined with an argument without > can turn a change anywhere on the page into work that requires checking the whole document. Keep the subject as close as possible, as in .form-row, and limit the argument with >, as in :has(> :user-invalid). When in doubt, measure: if you turn on "Enable CSS selector stats" in the Performance panel of Chrome DevTools and record a trace, the Selector Stats tab shows how long each selector took in the Recalculate Style events. Using that panel is covered in the Browser DevTools series post on the Performance panel; the general cost of style calculation is in the upcoming Web Performance series.

In Production

All selectors in this post are Baseline, but their dates differ. :is(), :where() and multi-argument :not() have been everywhere since 2021, :focus-visible since 2022, :has() and :user-invalid since late 2023, and :open only since May 2026. In a project whose target browsers include older versions, use [open] instead of :open. Since a rule written with :has() is ignored entirely in a browser that does not support it, the basic look should be acceptable without :has() as well; the tinted background of a card without a cover is an enhancement, not something the card needs to work.

Keep selectors readable for the team. A rule written with :has() is a rule that depends on the HTML structure: when someone wraps the cover image in a figure, .book-card:has(> img) stops matching. Next to such rules, write a short comment describing which markup they rely on.

Common Pitfalls

First, putting an id into an :is() list. Every selector in the list gains that id's weight and becomes hard to override. If you are writing a default style, use :where().

Second, losing readability while moving JavaScript state into CSS with :has(). A selector like .page:has(#cart-dialog[open]) .header:has(.menu:hover) works, but six months later nobody understands what affects what. If a piece of application state, such as "the cart is open", already lives in JavaScript, writing it as a class or a data attribute is usually clearer. Use :has() for states that follow naturally from the content: does the card have an image, does the row have an invalid field.

Third, showing form errors with :invalid. The user meets red fields the moment the page opens. Use :user-invalid.

Fourth, removing the focus ring entirely with outline: none. The right way to remove the ring that bothers mouse users is :focus-visible; keyboard users must not be left without a ring.

A Small Experiment

★☆☆ Replace :is(#featured, .book-card) in lab/02-specificity.html with :where(#featured, .book-card) and run node scripts/02-selectors.mjs again. Use the specificity column to explain which rule wins and why.

★★☆ Wrap the cover image of one card in the catalog in a figure. Measure the difference between .book-card:has(> img) and .book-card:has(img) and note which one survives this change.

★★★ Add an error message for the postcode to the checkout form, as a <p class="error">, and show it only when .form-row:has(> :user-invalid) matches. Add steps to the script that check the message appears when the user types an invalid postcode and leaves the field, and disappears once it is fixed.

In the next post we move from selectors to the box: the box model, box-sizing, margin collapse, intrinsic sizes such as min-content and max-content, aspect-ratio to keep a book cover's proportions, and logical properties that prepare a page for right-to-left languages.