Quantity Queries With :has(): Layouts That Respond to How Many Items There Are

A feature row looks right with three items and wrong with five. A navigation bar fits four links and needs to become a menu at seven. A photo gallery should show a single image full-width, two side by side, and three or more in a grid. None of these decisions depend on the viewport or even on the container's width; they depend on a count. This page shows how to express that count in CSS by combining :has() with the structural pseudo-classes, so the container itself changes layout when it gains or loses children. It sits within the Mastering Container Queries & Responsive Layouts guide.

Why count in CSS rather than in a template

The traditional answer is a class set by the template: gallery--count-3. It works until the content changes on the client — a filter hides items, a user deletes a card, a CMS editor adds one — and then the class is stale until something re-renders. A selector-based count cannot go stale, because the browser re-evaluates it on every DOM mutation. It also keeps the layout logic in the stylesheet, where the people changing layouts actually look.

Quantity queries have existed for a decade in a limited form. The old technique, li:nth-last-child(n+4), li:nth-last-child(n+4) ~ li, selects every item in a list of four or more, which lets you restyle the items. What it cannot do is restyle the list: change its grid-template-columns, switch it from flex to grid, or alter its gap. Before :has(), CSS had no way for a parent to react to anything about its children. Now it does, and the count becomes a first-class layout input.

The accessibility cost is nil, since nothing about the DOM or reading order changes. What changes is only how the same content is arranged, and the arrangement tracks the content automatically.

The three building blocks

Every quantity query is built from one fact: :nth-child(N) matches only if at least N siblings exist. Wrapped in :has(), that becomes a test on the parent.

At least, at most and exactly N At least three uses has nth-child three. At most three uses not has nth-child four. Exactly three combines both. Each row lists the child counts that match. Counting children with one pseudo-class at least 3 .list:has(> :nth-child(3)) 3, 4, 5 … at most 3 .list:not(:has(> :nth-child(4))) 0, 1, 2, 3 exactly 3 .list:has(> :nth-child(3)):not(:has(> :nth-child(4))) 3 only The child combinator matters: without it, grandchildren are counted too.

An alternative for "exactly N" is .list:has(> :nth-child(3):last-child) — the third child is also the last, so there are exactly three. It is shorter and reads nicely, but the two-part form generalises to ranges ("between three and five") by changing the second number, which makes it the better habit.

The child combinator > inside :has() is not optional decoration. Without it, :has(:nth-child(3)) matches if any descendant is the third child of its parent, so a card containing a three-item list inside it would make the outer grid think it has three cards.

The complete implementation

The gallery below changes its entire layout by count: one image spans full width with a wide ratio, two sit side by side, three form a feature layout with one large tile, and four or more become a regular grid. Add or remove figures in the markup and the layout follows. The demo renders the same stylesheet four times, with one, two, three and five figures, so every branch is visible at once; drag it narrower to watch the four-plus grid drop columns.

Live demoOne gallery rule set, four item counts
The same CSS lays out galleries of one, two, three and five figures differently — no classes, only :has() counting children. Drag the bottom-right corner to resize the frame in either direction.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quantity query gallery</title>
<style>
  body { font: 15px/1.5 system-ui, sans-serif; margin: 1.5rem; }

  .gallery {
    display: grid;
    gap: 0.5rem;
    /* Default for 1 item: a single full-width tile. */
    grid-template-columns: 1fr;
  }

  .gallery > figure {
    margin: 0;
    min-height: 8rem;
    border-radius: 8px;
    background: linear-gradient(135deg, #bfdbfe, #818cf8);
    display: grid;
    place-items: end start;
    padding: 0.5rem;
    color: #0f172a;
  }

  /* Exactly one item: wide cinematic ratio. */
  .gallery:not(:has(> :nth-child(2))) > figure {
    aspect-ratio: 21 / 9;
  }

  /* At least two: two equal columns. */
  .gallery:has(> :nth-child(2)) {
    grid-template-columns: repeat(2, 1fr);
  }

  /* Exactly three: the first tile becomes a tall feature on the left. */
  .gallery:has(> :nth-child(3)):not(:has(> :nth-child(4))) > :first-child {
    grid-row: span 2;
  }

  /* Four or more: a regular responsive grid, and no spanning. */
  .gallery:has(> :nth-child(4)) {
    grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  }
</style>
</head>
<body>
  <div class="gallery">
    <figure>One</figure>
    <figure>Two</figure>
    <figure>Three</figure>
  </div>
</body>
</html>

The rules are ordered from fewest to most items, and each later rule overrides the earlier ones where they overlap. At four items, both "at least two" and "at least four" match; the later repeat(auto-fill, …) wins by source order because the specificities are equal. The "exactly three" rule is written as a closed range so it cannot leak into the four-plus layout and span a tile in the regular grid.

The key technique: the parent is the subject

The reason this works is subtle and worth stating precisely. In .gallery:has(> :nth-child(3)), the subject of the selector — the element that receives the declarations — is .gallery. Everything inside the parentheses is a condition evaluated relative to it. That inversion is what :has() adds to CSS: the ability to style an element based on what it contains.

Styling the items versus styling the container Left, the legacy nth-last-child selector matches each of four items but cannot touch the list. Right, the has selector matches the list itself, so its grid template can change. Which element receives the declarations li:nth-last-child(n+4) ~ li ul: unreachable each item styled, list untouched ul:has(> :nth-child(4)) ul: grid template changes the container itself is styled

Because the container is the subject, the declarations can be layout declarations: display, grid-template-columns, flex-wrap, gap. That is the practical difference between the old item-based trick and the new parent-based one, and it is why quantity queries went from a curiosity to a mainstream layout tool once :has() shipped.

Variation: counting only visible items

A filterable list hides items with the hidden attribute or a class, and :nth-child counts hidden elements just like visible ones. The selector-list argument to :nth-child — the of S syntax — fixes this by counting only siblings that match a selector.

/* Count only items that are not hidden. With five items where two are
   hidden, :nth-child(4 of :not([hidden])) does not exist, so this list
   is treated as having three items and keeps the three-up layout. */
.results:has(> :nth-child(4 of :not([hidden]))) {
  grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
}

/* An explicit empty state when every item is filtered out. */
.results:not(:has(> :not([hidden]))) {
  display: grid;
  place-items: center;
  min-height: 8rem;
}

.results:not(:has(> :not([hidden])))::before {
  content: "No results match these filters.";
  color: #475569;
}

The empty-state rule is a small bonus: a container with no visible children can show its own message through generated content. For anything a screen reader must announce, keep the real message in the DOM inside a live region instead, because generated content is not reliably announced when it appears. The same "does this container hold anything?" logic powers content-aware card layouts with (), where the question is which kinds of children exist rather than how many.

Combining counts with width

A count tells you how many items there are; a container query tells you how much room they have. The two are independent inputs and compose cleanly: a navigation bar with up to four links can stay a row at any width above 24rem, while one with seven links needs 40rem before it can be a row.

.nav { container-type: inline-size; }

.nav__list { display: flex; flex-direction: column; gap: 0.25rem; }

/* Few links: a row as soon as there is modest room. */
@container (width > 24rem) {
  .nav__list:not(:has(> :nth-child(5))) { flex-direction: row; }
}

/* Many links: only a row when there is plenty of room. */
@container (width > 40rem) {
  .nav__list:has(> :nth-child(5)) { flex-direction: row; }
}

This is a genuinely new capability. Neither a media query nor a container query alone can express "the threshold depends on how much content there is", but the two together can. For the fully automatic version of this idea, where items wrap based on their own sizes, see responsive navigation without media queries.

Browser support

:has() is supported in Chrome and Edge 105+, Firefox 121+ and Safari 15.4+. :nth-child() itself is universal, and the of S selector-list argument is supported in current versions of all three engines, having arrived in Safari long before the others. In a browser without :has(), every rule on this page is dropped, and the gallery keeps its single-column default — a safe fallback. To keep the two-column layout for older engines, wrap the :has() rules in @supports selector(:has(*)) and give the default rule two columns outside that block.

FAQ

How do I select a container that has at least four children? Use .list:has(> :nth-child(4)). The fourth child only exists when there are four or more, so the parent matches exactly when the count is four or higher.

How do I select a container with exactly three children? Combine a lower and an upper bound: .list:has(> :nth-child(3)):not(:has(> :nth-child(4))). The first part requires at least three, the second rules out four or more.

Do hidden children count toward a quantity query? Yes. :nth-child counts elements in the DOM regardless of display: none or the hidden attribute, so a filtered list that hides items with CSS still reports its full count. Remove items from the DOM or add an of S filter such as :nth-child(4 of :not([hidden])) when only visible items should count.

What did quantity queries look like before :has()? They styled the children rather than the parent, using selectors like li:nth-last-child(n+4), li:nth-last-child(n+4) ~ li to match every item in a list of four or more. That works for the items but can never change the container's own grid or flex rules, which :has() now can.

Related articles

More pages in the same section.