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.
: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.
/* 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.
<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>.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.
: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.: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.
header nav a:hover,
main nav a:hover,
footer nav a:hover,
aside nav a:hover {
color: var(--accent);
}: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.
/* 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;
}: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.
/* :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.
/* 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; }: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. */: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.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.
/* 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.
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.
.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 */
}@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.
.card-wrapper {
container: card / inline-size;
/* shorthand: <container-name> / <container-type> */
}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.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.
.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.
A Shared Product Card Component at a Seattle Retail Platform
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.
/* 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
.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.
Five Misconceptions About Modern Selectors
6 Interview Questions — With Complete Answers
Modern Selector Mistakes Engineers Make Constantly
.card {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
/* fails silently — .card can't query its OWN container */
flex-direction: row;
}
}.card-wrapper {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
/* .card is a DESCENDANT of the query container, .card-wrapper */
flex-direction: row;
}
}/* 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 */:where(.btn-base) {
padding: 10px 16px;
/* :where() contributes ZERO specificity — any later rule matching
.btn-base, even a single plain class, wins automatically */
}.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 */
}.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 */
}@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 */.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 */
}Errors and Rendering Bugs You Will Hit With Modern Selectors — And Exactly Why
🎯 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 ConventionsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.