Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Intermediate+200 XP

Modern Selectors — :has, :is, :where, Container Queries

The newest selectors that changed how CSS is written — the parent selector finally arrives, plus container queries for truly component-based responsive design.

35 min August 2026
// Part 01 — :has()

:has() — The Parent Selector CSS Never Had, Until Now

For as long as CSS has existed, selectors could only describe an element by looking at itself or its ancestors — never by looking at its own descendants and reacting to what is found there. You could style a <div> because it was inside a .card, but you could never style the .card itself based on what happened to be sitting inside it. This missing capability was informally called "the parent selector problem," and it was one of the most frequently requested CSS features for over a decade. :has() finally solves it.

:has() styles an element based on what it CONTAINS
/* Style a .card differently, but ONLY if it contains an <img> */
.card:has(img) {
  border: 2px solid var(--accent);
}

/* Style a form field wrapper differently if the input inside is invalid */
.field:has(input:invalid) {
  border-color: red;
}

/* Style a .field differently if it has a CHECKED checkbox inside it */
.field:has(input[type="checkbox"]:checked) {
  background: #eefbea;
}

Read :has() as "an element that has, somewhere inside it, something matching this selector." The argument to :has() can be any valid selector — a tag name, a class, an attribute selector, a pseudo-class like :checked or :invalid, or a combination of several, exactly as complex as any selector you would write elsewhere.

The practical use case that comes up constantly: styling a label from its input's state

One of the single most common real-world uses of :has() is styling a form field's label (or its entire wrapper) based on the validation state of the input living next to it — something that previously required JavaScript, because CSS could style the input itself with :invalid, but had no way to reach "upward and sideways" to the label.

Live validation styling with zero JavaScript
<div class="form-field">
  <label for="email">Email address</label>
  <input type="email" id="email" required>
  <span class="error-text">Please enter a valid email</span>
</div>
The CSS — :has() reaches into the field and reacts to the input's state
.form-field .error-text {
  display: none;
}

/* Show the error text and highlight the label ONLY when the input
   inside this field is both invalid AND has already been interacted with */
.form-field:has(input:invalid:not(:placeholder-shown)) label {
  color: #d32f2f;
}

.form-field:has(input:invalid:not(:placeholder-shown)) .error-text {
  display: block;
  color: #d32f2f;
}

.form-field:has(input:valid) label {
  color: #2e7d32;
}

:not(:placeholder-shown) is the standard trick for avoiding a "wrong from the moment the page loads" error message on an empty required field — it only counts as a match once the placeholder is no longer showing, meaning the user has actually typed something. Before :has(), this entire interaction required a JavaScript event listener watching the input and manually toggling a class on the label; now it is three CSS rules with no script involved at all.

🎯 Pro Tip
:has() also unlocks conditional layout that used to require extra wrapper divs or JavaScript class toggling — for example, .grid:has(> :nth-child(5)) can apply a different grid layout only when a container has five or more direct children, letting your CSS react to how much content actually ended up there.
// Part 02 — :is()

:is() — Collapsing Repetitive Selector Lists

:is() takes a list of selectors and matches any element that matches at least one of them — its entire purpose is letting you write a shared "trunk" of a selector once, instead of repeating it across several nearly-identical rules.

Without :is() — the same trailing selector repeated four times
header nav a:hover,
main nav a:hover,
footer nav a:hover,
aside nav a:hover {
  color: var(--accent);
}
With :is() — the varying part is factored into one place
:is(header, main, footer, aside) nav a:hover {
  color: var(--accent);
}

This is not just shorter — it is genuinely easier to maintain, because adding a fifth context (say, a .sidebar) is a one-word edit inside the parentheses, rather than an entirely new duplicated selector line that has to be kept in sync with the other four by hand.

:is() can appear anywhere in a selector, not just at the start
/* Match any heading (h1 through h4) that is the first child of ANY of
   these three container types */
:is(article, section, aside) > :is(h1, h2, h3, h4):first-child {
  margin-top: 0;
}
// Part 03 — :where()

:where() — Identical Matching, Zero Specificity

:where() matches exactly the same elements as :is() given the same arguments — the only difference between them is specificity, and that difference is the entire reason both exist rather than just one.

The one and only difference between :is() and :where()
/* :is() takes the specificity of its MOST SPECIFIC argument */
:is(#sidebar, .card, p) { color: blue; }
/* This selector's specificity is that of #sidebar — an ID — even though
   .card and p are also valid matches. The whole rule inherits the HIGHEST
   specificity among its arguments. */

/* :where() ALWAYS has zero specificity, no matter what's inside it */
:where(#sidebar, .card, p) { color: blue; }
/* This selector has ZERO specificity — literally 0,0,0,0 — regardless of
   the fact that #sidebar is an ID. It is as if the selector wasn't
   specific at all. */

This matters enormously for anyone building a reusable component library or a base stylesheet meant to be easily overridden. Base styles written with :where() are trivially easy for a consumer of the library to override with a single, low-specificity class — because the library's own selector contributes nothing to the specificity fight at all.

A design-system base style, easily overridable because it uses :where()
/* Design system base file */
:where(.btn) {
  padding: 10px 18px;
  border-radius: 6px;
  font-weight: 600;
}

/* Consumer's page — this single class easily wins, because the
   base rule above contributes ZERO specificity */
.btn { padding: 8px 14px; }
The same base style written with :is() instead — much harder to override
:is(.btn) {
  padding: 10px 18px;
  border-radius: 6px;
  font-weight: 600;
}
/* :is(.btn) has the specificity of a single class — .10px value — so a
   plain .btn override rule written later still wins here (same specificity,
   later rule in source order wins), but the moment the base file wraps
   ANYTHING more specific in :is(), overriding gets meaningfully harder
   than it would be with :where(), which is why component libraries default
   to :where() for anything meant to be a customizable base style. */
💡 Note
A simple rule for choosing between them: reach for :where() whenever you are writing base or reset styles meant to be easy for someone else (or future-you) to override later. Reach for :is() when you specifically want the selector's specificity to matter and compete normally with the rest of your cascade, exactly like any ordinary selector would.
// Part 04 — Container Queries: The Concept

Why Media Queries Were Never Enough for Real Components

A media query answers exactly one question: how big is the viewport? That is useful for page-level layout decisions — should the page use a single column or three — but it breaks down the moment you build genuinely reusable components, because a component's available width is very often not the same as the viewport's width. A card component might render at full viewport width in one place and inside a narrow 240px sidebar in another — a media query has no way to tell those two situations apart, because both happen on the exact same viewport size.

The problem media queries cannot solve
/* This media query fires based on the VIEWPORT — but the same .card
   component might be rendered in a wide main column OR a narrow sidebar
   at the exact same viewport width. Media queries cannot tell them apart. */
@media (min-width: 700px) {
  .card { display: flex; }
}

Container queries invert the question entirely: instead of asking "how wide is the viewport," they ask "how wide is the space THIS component has actually been given by its own container?" That is a fundamentally more useful question for a component meant to be dropped into different layout contexts across a real site.

// Part 05 — Container Queries: The Syntax

container-type and @container — Setting Up and Using a Query Container

Using a container query is a two-step process. First, you mark an element as a "query container" using the container-type property — this opts that element in to having its size tracked. Second, you write an @container rule that targets descendants of that container, based on the container's size rather than the viewport's.

Step 1 — mark the container
.card-wrapper {
  container-type: inline-size;
  /* "inline-size" means: track the container's width (in a standard
     left-to-right, top-to-bottom writing mode). This is by far the
     most common value — container queries are almost always about width. */
  container-name: card;
  /* optional — names the container so @container rules can target it
     specifically, useful when containers are nested */
}
Step 2 — query the container's size
@container card (min-width: 400px) {
  .card {
    display: flex;
    flex-direction: row;
  }
  .card-image {
    width: 40%;
  }
}

@container card (max-width: 399px) {
  .card {
    display: flex;
    flex-direction: column;
  }
}

Every rule inside the @container block applies based on that container's measured width — completely independent of the browser viewport. The exact same .card component, with this CSS, lays out horizontally when its container happens to be wide (a main content column) and stacks vertically when its container happens to be narrow (a sidebar widget) — on the identical viewport width, at the identical moment, in the identical page.

The shorthand form of container-type + container-name
.card-wrapper {
  container: card / inline-size;
  /* shorthand: <container-name> / <container-type> */
}
⚠️ Important
An element cannot query its own size — container-type must be set on an ancestor of whatever you are trying to style with @container, not on the element itself. This trips up almost everyone the first time: setting container-type: inline-size directly on .card and then writing @container (min-width: 400px) { .card { ... } } simply does not work, because a container query rule always targets descendants of the container, never the container itself.
// Part 06 — Container Query Units

cqw, cqh, cqi, cqb — Sizing Relative to the Container, Not the Viewport

Alongside @container rules, CSS also introduced a set of length units that resolve against the query container's dimensions, the same way vw and vh resolve against the viewport's dimensions. These let you size things proportionally to the component's own available space, without needing an @container block at all for simple proportional scaling.

Container query units
.card-title {
  font-size: 8cqi;
  /* 8% of the query container's INLINE size (its width, in a standard
     writing mode) — the title scales up and down smoothly as the
     card's own container gets wider or narrower */
}

/* cqw  = 1% of the container's width
   cqh  = 1% of the container's height
   cqi  = 1% of the container's inline size (usually == cqw)
   cqb  = 1% of the container's block size (usually == cqh) */

These units require an ancestor with container-type set, exactly like @container rules do — without one, they fall back to behaving like the equivalent viewport unit in most browsers, which can silently produce the wrong sizing if you forget the container setup and only notice once the component is dropped into a genuinely narrow spot.

// Part 07 — Real World
💼 What This Looks Like at Work

A Shared Product Card Component at a Seattle Retail Platform

Scenario — Retail platform, Seattle · Shared component library

A Seattle-based retail platform's design system team maintains a single ProductCard component used in three very different places: a wide three-column grid on the main shop page, a narrow single-column "recently viewed" rail in the sidebar, and a compact horizontal strip inside the checkout page's order summary. All three placements can appear on the exact same page, at the exact same viewport width.

The old approach — a media query, plus a manual layout prop threaded through React
/* This can only react to the viewport, so engineers added a
   "compact" boolean prop, manually passed down from every parent
   that happened to know it was rendering the card in a narrow spot */
.product-card.compact {
  flex-direction: row;
}
.product-card {
  flex-direction: column;
}

Every new placement of ProductCard required someone to remember to pass the right layout prop by hand, based on knowledge of where the component happened to be getting rendered — knowledge that lived in the parent component, not the card itself. It broke twice in one quarter: once when the sidebar was widened during a redesign and nobody updated the prop, and once when a new "similar items" placement was added and the engineer simply forgot the prop existed.

The fix — the card decides its own layout, based on its own container

The container-query version — no prop, no parent knowledge required
.product-card-slot {
  container-type: inline-size;
  container-name: product-card;
}

.product-card {
  display: flex;
  flex-direction: column;
}

@container product-card (min-width: 320px) {
  .product-card {
    flex-direction: row;
  }
  .product-card-image {
    width: 45%;
  }
}

The ProductCard component itself now has zero knowledge of where it is being rendered — it simply measures the space its own wrapper was actually given and lays itself out accordingly. Dropping it into a new, narrower placement — the exact scenario that broke twice before — now just works automatically, with no prop to remember and no parent-side configuration to keep in sync. This is precisely the shift container queries represent: component-level responsiveness that travels with the component, instead of page-level responsiveness that has to be manually re-derived every time the component moves.

// Part 08 — Misconceptions

Five Misconceptions About Modern Selectors

✕ "":has() is just a fancy way to select a specific descendant""
:has() selects the ANCESTOR based on what's inside it — the opposite direction from every other combinator in CSS. .card:has(img) selects the .card, not the img. This is precisely why it was called "the parent selector" — nothing else in CSS lets a selector's matched element depend on its own descendants.
✕ "":is() and :where() are interchangeable — pick whichever one you remember""
They match identically, but :is() takes the specificity of its highest-specificity argument while :where() always contributes zero specificity. Using the wrong one can make a rule impossible to override later (with :is()) or unexpectedly easy to override (with :where()) — the choice is a deliberate specificity decision, not a stylistic preference.
✕ ""Container queries replace media queries entirely""
They solve different problems and are commonly used together in the same project. Media queries remain the right tool for page-level, viewport-driven layout decisions (like switching a whole page from one column to three); container queries are for a component that needs to respond to the space IT was actually given, independent of the viewport.
✕ ""You can set container-type directly on the element you want to style with @container""
An element cannot query its own size — container-type must be set on an ANCESTOR of whatever the @container rule targets. Setting it on the same element as the styled selector inside @container silently fails to work.
✕ "":has() can only check for direct children""
:has() matches any DESCENDANT by default, at any depth, exactly like a normal descendant combinator — .card:has(img) matches even if the img is nested several levels deep inside the card. Restricting it to direct children requires the child combinator explicitly, as in .card:has(> img).
// Part 09 — Interview Prep

6 Interview Questions — With Complete Answers

What problem does :has() solve that CSS could never solve before?
It is the first native CSS selector that can style an element based on its DESCENDANTS rather than only itself or its ancestors — informally "the parent selector." Before :has(), styling a container differently depending on what happened to be inside it (e.g. highlighting a form field wrapper because the input inside is invalid, or a card because it contains an image) required JavaScript to toggle a class manually.
What is the exact difference between :is() and :where(), given they match the same elements?
:is() takes on the specificity of its single highest-specificity argument — so :is(#id, .class) has the specificity of an ID selector. :where() always has zero specificity, regardless of what is inside it, even an ID. This matters for how easy a rule is to override later: :where()-based rules are trivial to override with a single low-specificity class, which is why component libraries and CSS resets favor it for base styles.
Why can't a component author reliably use a media query to make a reusable card component responsive to its own placement?
A media query only knows the viewport's width — it cannot distinguish between the same component rendered in a wide main column versus a narrow sidebar at the identical viewport size, because both situations produce the same media query result. Container queries solve this by measuring the actual width the component's own container was given, independent of the viewport.
What two steps are required to use a container query, and what is the most common mistake made when setting one up?
First, mark an ancestor element with container-type (typically inline-size) to opt it into being measured. Second, write an @container rule targeting descendants of that container. The most common mistake is setting container-type on the same element being styled inside the @container rule — a container cannot query its own size, only descendants of a container can be targeted.
Give a concrete real-world use case for :has() beyond a toy example.
Styling a form field's label and error text based on the live validation state of the input inside it — e.g. .form-field:has(input:invalid:not(:placeholder-shown)) label turns the label red once the user has typed something invalid, with zero JavaScript. Another common one: styling a card component differently depending on whether it happens to contain an image, e.g. .card:has(img).
What are container query units (cqw, cqh, cqi, cqb), and how do they differ from vw/vh?
They are length units that resolve as a percentage of a query container's size instead of the viewport's size — cqi and cqw are typically the container's width, cqb and cqh its height. They require an ancestor with container-type set, exactly like @container rules, and let something like font-size scale proportionally to a component's own available space rather than the page's overall viewport.
// Common Mistakes

Modern Selector Mistakes Engineers Make Constantly

Broken
.card {
  container-type: inline-size;
}

@container (min-width: 400px) {
  .card {
    /* fails silently — .card can't query its OWN container */
    flex-direction: row;
  }
}
Fixed
.card-wrapper {
  container-type: inline-size;
}

@container (min-width: 400px) {
  .card {
    /* .card is a DESCENDANT of the query container, .card-wrapper */
    flex-direction: row;
  }
}
Broken
/* Meant as a low-specificity, easily overridable base rule */
:is(.btn-base) {
  padding: 10px 16px;
}
/* A later ".btn" rule with equal specificity may lose in source-order
   ties, and the intent to make this trivially overridable is lost */
Fixed
:where(.btn-base) {
  padding: 10px 16px;
  /* :where() contributes ZERO specificity — any later rule matching
     .btn-base, even a single plain class, wins automatically */
}
Broken
.field:has(input:invalid) label {
  color: red;
  /* fires immediately on page load for an empty required field,
     before the user has typed anything at all */
}
Fixed
.field:has(input:invalid:not(:placeholder-shown)) label {
  color: red;
  /* :not(:placeholder-shown) excludes the untouched, empty state —
     only matches once the user has actually typed something invalid */
}
Broken
@container (min-width: 400px) {
  .card { flex-direction: row; }
}
/* No container-name given anywhere — works, but in a page with
   several nested containers this rule can silently match the
   WRONG ancestor container */
Fixed
.card-wrapper {
  container: card-slot / inline-size;
}

@container card-slot (min-width: 400px) {
  .card { flex-direction: row; }
  /* naming the container removes any ambiguity about which
     ancestor's size this rule is actually responding to */
}
// Error Library

Errors and Rendering Bugs You Will Hit With Modern Selectors — And Exactly Why

A rule using :has(), :is(), or a container query has no effect at all, and DevTools shows the declaration crossed out or the whole rule missing from computed styles
Cause: The browser (or a specific version of it) does not support the feature. :has() reached broad cross-browser support noticeably later than :is()/:where(), and container queries later still — a stylesheet targeting older browser versions can silently fail to apply these rules with no console warning at all, since invalid/unsupported selectors are simply ignored by the CSS parser.
Fix: Check current browser support before relying on these features for anything critical, and provide a reasonable fallback layout that still looks acceptable without them, since unsupported selectors do not error — they are just silently skipped, and a page relying entirely on :has() with no fallback can look visually broken in an unsupported browser with no error trail to follow.
@container rule never fires, and the container-queried element never changes layout
Cause: container-type was set on the wrong element — usually the same element being styled inside the @container block, rather than one of its ancestors. A container query rule always targets descendants of the container that has container-type set.
Fix: Move container-type: inline-size to a wrapping ancestor element, one level (or more) up from the element you are actually trying to style inside the @container rule.
Container query units (cqw/cqi/etc.) resolve as if they were the equivalent viewport unit, producing unexpectedly large or small sizes
Cause: No ancestor has container-type set, so there is no query container context for the unit to resolve against — most browsers fall back to viewport-relative sizing in this situation rather than erroring.
Fix: Confirm an ancestor element has container-type: inline-size (or size) set, and that the element using the cq* unit is actually a descendant of it.
A :where()-based base style is unexpectedly overridden by a browser's own default user-agent stylesheet
Cause: :where() intentionally contributes zero specificity, which means it can lose not just to your own later rules (by design) but occasionally to unexpected sources of specificity as well, if the base rule was relied on to "win" against something it was never actually competing against fairly.
Fix: This is rarely a bug in the :where() rule itself — check whether a CSS reset or normalize stylesheet is loaded before your base styles, and confirm source order, since :where() rules depend entirely on winning through specificity being genuinely zero, not through any special override behavior.
A :has() selector containing a pseudo-class like :focus-within inside it behaves inconsistently across browsers
Cause: :has() combined with certain dynamic pseudo-classes (like :focus-within, :hover) inside its argument is a newer, more specific combination than plain :has(), and had uneven rollout timing across browser engines even after basic :has() support landed.
Fix: Test the specific combination directly in each target browser rather than assuming baseline :has() support implies every nested pseudo-class combination inside it behaves identically — verify on caniuse.com or directly test in the browsers your users actually use.

🎯 Key Takeaways

  • :has() is CSS's native "parent selector" — it styles an element based on what it CONTAINS, the opposite direction from every other combinator, enabling things like validation-state-driven label styling with zero JavaScript.
  • :is() and :where() match identically given the same arguments — the only difference is specificity. :is() takes its highest-specificity argument; :where() is always zero specificity.
  • Use :where() for base/reset styles meant to be trivially overridable later; use :is() when the selector's specificity should genuinely compete in the normal cascade.
  • Media queries respond to the viewport; container queries respond to the actual space a component's own container was given — the two solve different problems and are commonly used together.
  • A container query requires container-type set on an ANCESTOR of the styled element — an element cannot query its own size.
  • Container query units (cqw, cqh, cqi, cqb) size relative to the query container, the same way vw/vh size relative to the viewport — useful for proportional scaling without a full @container block.
  • :has() matches descendants at any depth by default, not just direct children — use the child combinator (:has(> img)) to restrict it.
  • None of these features error when unsupported — they are silently skipped by older browsers, so always verify current support and provide a reasonable fallback for anything critical.

What comes next

Module 35 covers CSS architecture and naming conventions — BEM with a full worked example, why naming systems matter once a project grows past a handful of files, and avoiding the overly-specific selectors and !important overuse that make stylesheets painful to maintain.

Module 35 → CSS Architecture & Naming Conventions
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...