RizTech Academy logo
RizTech Academy
Modern CSS and WorkflowLesson 2 of 525 min

:has, :is, :where and nesting

Four selectors arrived in CSS in the last few years, and between them they removed a genuine amount of JavaScript from the average site. :has() in particular is the thing CSS lacked for twenty years.

:has() — the parent selector

.card:has(img) { padding: 0; }

"A card that contains an image." CSS could never express that before, because selectors only ever looked downwards. The workaround was a class added by JavaScript, or a separate .card--with-image class you had to remember to write.

It is not really a "parent selector" — it is a relational one. The subject is whatever is on the left, and :has() is a condition about its descendants or siblings.

.card:has(img)                  /* contains an img, at any depth */
.card:has(> img)                /* has an img as a DIRECT child */
label:has(+ input:required)     /* is followed by a required input */
.form:has(input:invalid)        /* contains an invalid field */
article:has(h2, h3)             /* contains either */
li:not(:has(ul))                /* a leaf item */

Note :has(> img) versus :has(img). The relative selector inside starts from the subject, so a leading combinator constrains it. That distinction matters as often as nav > a versus nav a did in module 2.

What it is actually good for

Four patterns you will use:

Form state, with no JavaScript:

/* mark a required field's label */
label:has(+ input:required)::after {
  content: " *";
  color: var(--colour-error);
}

/* style the whole field group when it is invalid */
.field:has(input:user-invalid) {
  --field-border: var(--colour-error);
}

/* disable a submit button's look while the form is invalid */
form:has(:invalid) .submit { opacity: 0.6; }

Layout that depends on content:

.article:has(figure) { --content-width: 70ch; }
.card:has(.badge) { padding-block-start: 2rem; }

Quantity queries — styling based on how many children there are:

.grid:has(> :nth-child(4)) { grid-template-columns: repeat(2, 1fr); }

"If there is a fourth child, go two columns." Previously impossible without counting in JavaScript.

Reaching upwards from a state:

body:has(dialog[open]) { overflow: hidden; }

Lock page scroll while a dialog is open, from CSS. That single rule replaces a script that adds and removes a class on <body>.

The two limits

:has() cannot be nested inside another :has(), and it cannot contain a pseudo-element. Those are specification rules, not bugs.

Specificity comes from the most specific argument, like :is(). So .card:has(#special) is 1,1,0 — an id's worth of specificity from inside the brackets, which is easy to do by accident.

:is() — grouping, with specificity

:is(h1, h2, h3, h4) { line-height: 1.2; }

/* the old way, and the reason :is() exists */
.prose h1, .prose h2, .prose h3, .prose h4 { line-height: 1.2; }
.prose :is(h1, h2, h3, h4) { line-height: 1.2; }

It shortens long selector lists, and it does one thing a comma cannot: a comma-separated list is invalidated selector-by-selector, while :is() is forgiving. If one argument inside :is() is nonsense, that argument is ignored and the rest still work — which matters when you are using a selector not every browser knows.

Its specificity is that of its most specific argument, which is the trap:

:is(h1, #title) { color: red; }     /* specificity 1,0,0 — because of the id */

:where() — grouping, with zero specificity

Identical to :is(), except it always counts as 0,0,0, including its arguments.

That single difference is its entire purpose. It is the tool for writing defaults that anybody can override with one plain class:

/* a reset somebody can override trivially */
:where(ul, ol) {
  margin: 0;
  padding: 0;
  list-style: none;
}

.bulleted { list-style: disc; padding-inline-start: 1.5rem; }  /* 0,1,0 — wins easily */

Written with :is() that reset would be 0,0,1 and the class would still win — but the real gain shows up with longer selectors:

:where(.prose :is(h1, h2, h3)) { margin-block: 1.5em 0.5em; }  /* still 0,0,0 */

The rule: :where() for your own defaults and resets, :is() for shortening a selector you actually want to have specificity. Every serious modern reset uses :where() for this reason, and it is why the module 2 reset would be better written with it.

Nesting

.card {
  padding: 1rem;
  border: 1px solid var(--colour-border);

  &:hover {
    border-color: var(--colour-accent);
  }

  & .card__title {
    font-size: 1.25rem;
  }

  .card__body & {          /* & can go anywhere: this is .card__body .card */
    padding: 0;
  }

  @media (width >= 40rem) {
    padding: 2rem;
  }

  &:has(img) {
    padding: 0;
  }
}

Native nesting, no build step. Everything about a component in one block, including its media queries — which is the module 4 recommendation, now with nothing to indent around.

Three rules worth knowing:

& is the parent selector reference, and it can appear anywhere, including after something else.

A nested selector that starts with an element name needs & or it is ambiguous. .card { p { … } } works in current browsers, but .card { & p { … } } is unambiguous and was required in earlier implementations. Writing & is the safer habit.

Nesting increases specificity the same way descendant selectors do. It does not make specificity go away — .card { & .title { } } is 0,2,0, exactly as .card .title is. The best-practices warning about long descendant chains applies unchanged, and nesting makes it easier to write a five-level chain by accident. Keep it to one or two levels.

:focus-within and :focus-visible

.field:focus-within {
  --field-border: var(--colour-accent);
}

.search:focus-within .search__suggestions {
  display: block;
}

:focus-within matches an element that contains focus. It is what lets a whole field group highlight when its input is focused, or a dropdown stay open while anything inside it has focus — which is the correct, accessible version of a hover-only menu from module 2.

:focus-visible you met in module 2 and again in module 5: keyboard focus, not mouse clicks.

The rest worth knowing

input:user-invalid          /* invalid, but only after they have interacted */
input:user-valid
.item:nth-child(2n + 1 of .active)   /* nth-child within a filtered set */
::selection                 /* the highlight colour */
input::placeholder
::marker                    /* a list item's bullet or number */
::first-line

:user-invalid over :invalid, as module 1 said — plain :invalid turns every required field red the moment the page loads.

::marker is worth knowing because styling a bullet used to require removing it and faking one with ::before:

li::marker {
  color: var(--colour-accent);
  font-weight: 700;
}

Only color, font-*, content and a few others apply to ::marker, but that covers what people actually wanted.

What this does not replace

Honest limits, because the enthusiasm around :has() overstates it.

CSS still cannot react to events — a click that must persist state, a fetch, a calculation. :has() reads the DOM as it is; something still has to change the DOM.

The pattern it does replace is the very common one of JavaScript adding a class purely to describe the DOM — has-image, is-empty, form-invalid, menu-open. Those were always derivable from the DOM, and now CSS can derive them. That is a real reduction, and it is not "no JavaScript".

Support

:has(), :is(), :where(), :focus-within, :user-invalid and native nesting are all supported in every current browser. :has() was the last to arrive and is the one to check on caniuse.com if you support anything old.

The fallback behaviour differs in an important way: an unsupported :has() invalidates the entire rule, so the declarations do not apply at all. Which means you can use it as progressive enhancement — write the base styles normally and let the :has() rule refine them — but never rely on it for something essential without a fallback.

Check your work

What :has() is. A relational selector — the subject is on the left.

:has(img) versus :has(> img). Any depth versus a direct child.

Four patterns it enables. Form state, content-dependent layout, quantity queries, and reaching upwards from a state.

Two limits of :has(). No nesting inside another :has(), and no pseudo-elements.

:is() versus a comma list. :is() is forgiving — one bad argument does not kill the rest.

Where :is() specificity comes from. Its most specific argument, so an id inside is an id's worth.

Why :where() exists. Always zero specificity, so defaults can be overridden by one plain class.

The rule for the two. :where() for your defaults and resets, :is() for shortening something that should have specificity.

What nesting does to specificity. Nothing helpful — it is the same as the equivalent descendant selector, and it makes long chains easier to write by accident.

Why & even when not strictly needed. It removes ambiguity and matches earlier implementations.

What :focus-within is for. Styling a container that contains focus — the accessible version of a hover menu.

What ::marker saved us from. Removing the bullet and faking one with ::before.

What :has() does not replace. Reacting to events. It replaces JavaScript that added a class merely to describe the DOM.

What an unsupported :has() does. Invalidates the whole rule, so use it as enhancement.

Practice

  1. Style a card differently when it contains an <img>, then constrain it to a direct child and nest the image one level deeper.
  2. Add a * to every required field's label with :has(+ input:required).
  3. Highlight a field group when its input is :user-invalid.
  4. Fade a submit button while form:has(:invalid).
  5. Change a grid's column count with :has(> :nth-child(4)) and add items one at a time.
  6. Lock page scroll with body:has(dialog[open]) and open a <dialog>.
  7. Work out the specificity of .card:has(#special) by hand, then check it in devtools.
  8. Put a nonsense selector inside :is() and inside a comma list. Compare what survives.
  9. Write a reset with :where(), then with :is(), and try to override each with one class.
  10. Convert one component to native nesting, including its media query.
  11. Nest four levels deep and check the resulting specificity in devtools.
  12. Write a nested rule without & and one with it.
  13. Use :focus-within to reveal a dropdown, then tab into and out of it.
  14. Style ::marker on a list, and ::selection on the page.
  15. Style input:invalid on page load, then switch to :user-invalid.
  16. Look up :has() support on caniuse.com, then deliberately break a :has() rule's syntax and confirm the entire rule stops applying.

Official documentation

Next: forms, which are harder to style than everything above put together.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship