Home / Articles / Modern CSS /
Quantity queries with :has()
Adapt layouts based on how many children an element has — switch from list to grid at 4 items, show empty states, and build responsive component logic without media queries.
The old quantity query pattern
Before :has(), quantity queries used a clever but hard-to-read :nth-last-child trick. You would target the first child only when a certain number of siblings existed, then use ~ * to style all of them. It worked, but it could only style the children — never the parent container.
/* Old pattern: style children when there are 4+ items */
li:nth-last-child(n+4),
li:nth-last-child(n+4) ~ li {
/* targets items when there are 4 or more */
font-size: 0.9rem;
}
/* New pattern with :has() — style the PARENT */
ul:has(> li:nth-child(4)) {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
The :has() version reads naturally: "select any ul that has a 4th li child." This targets the container, not the items, giving you full control over the layout.
Counting children with :nth-child()
The key insight: :nth-child(n) inside :has() checks whether an nth child exists. If the 4th child exists, the container has at least 4 children.
/* Exactly 1 item */
.list:has(> :first-child:last-child) {
display: block;
}
/* Exactly 2 items */
.list:has(> :nth-child(2):last-child) {
display: grid;
grid-template-columns: 1fr 1fr;
}
/* Exactly 3 items */
.list:has(> :nth-child(3):last-child) {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
/* 4 or more items */
.list:has(> :nth-child(4)) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
}
For "exactly N" counts, combine :nth-child(N) with :last-child. For "N or more," just check if the Nth child exists. For "fewer than N," negate: :not(:has(> :nth-child(N))).
Empty states and zero-item handling
One of the most useful quantity query patterns is detecting when a container has no items and showing an empty state — something that usually requires a JavaScript check or server-side conditional.
/* Container with no list items */
.results:not(:has(> .result-item)) {
display: grid;
place-items: center;
min-height: 12rem;
}
.results:not(:has(> .result-item))::after {
content: "No results found";
color: oklch(0.63 0.02 260);
font-size: 1.1rem;
}
/* Hide the "showing N results" label when empty */
.results:not(:has(> .result-item)) + .result-count {
display: none;
}
/* Hide column headers when table body is empty */
table:has(tbody:not(:has(tr))) thead {
display: none;
}
The ::after pseudo-element creates an empty-state message without any extra HTML. When items appear, the message vanishes and the layout adapts automatically.
Adaptive card grids
Build a card grid that changes its layout strategy based on how many cards it contains — without media queries or container queries.
.card-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* 2 cards: side by side */
.card-grid:has(> .card:nth-child(2)) {
flex-direction: row;
}
/* 3 cards: featured + 2 small */
.card-grid:has(> .card:nth-child(3):last-child) {
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-rows: auto auto;
}
.card-grid:has(> .card:nth-child(3):last-child) .card:first-child {
grid-row: 1 / -1;
}
/* 4+ cards: equal grid */
.card-grid:has(> .card:nth-child(4)) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
}
/* 7+ cards: compact mode */
.card-grid:has(> .card:nth-child(7)) .card {
padding: 0.75rem;
font-size: 0.9rem;
}
Each threshold produces a different layout: stack, row, featured grid, equal grid, or compact grid. The component self-adapts to its content count.
Navigation adapting to item count
A navigation bar might switch from horizontal links to a dropdown or hamburger menu based on how many items it contains — a layout decision driven by content, not viewport.
/* Few items: horizontal row */
.nav-list {
display: flex;
gap: 1.5rem;
}
/* 6+ items: shrink gaps and font */
.nav-list:has(> li:nth-child(6)) {
gap: 0.75rem;
font-size: 0.9rem;
}
/* 8+ items: wrap to two rows */
.nav-list:has(> li:nth-child(8)) {
flex-wrap: wrap;
row-gap: 0.5rem;
}
/* 10+ items: switch to grid columns */
.nav-list:has(> li:nth-child(10)) {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: 0.5rem;
}
/* Show "more" button only when overflow is likely */
.nav:has(.nav-list > li:nth-child(8)) .more-btn {
display: inline-flex;
}
Combining with container queries
Quantity queries answer "how many items?" while container queries answer "how much space?" Together they create truly content-aware components.
.tag-list {
container-type: inline-size;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* Few tags in a small container: stack vertically */
@container (max-width: 20rem) {
.tag-list:not(:has(> :nth-child(4))) {
flex-direction: column;
}
}
/* Many tags: compact pills */
.tag-list:has(> :nth-child(8)) .tag {
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
}
/* Too many tags: show count instead */
.tag-list:has(> :nth-child(12)) > :nth-child(n+6) {
display: none;
}
.tag-list:has(> :nth-child(12))::after {
content: "+more";
color: oklch(0.72 0.19 265);
font-size: 0.8rem;
align-self: center;
}
When there are 12 or more tags, items beyond the 5th are hidden and a "+more" indicator appears. This approach avoids JavaScript truncation logic entirely.
Performance notes
Quantity queries with :has() are efficient because :nth-child() is a constant-time check — the browser does not iterate all children. However, avoid deeply nested :has() chains.
/* Fast: direct child count */
.grid:has(> :nth-child(4)) { /* ... */ }
/* Also fast: combines with :last-child */
.grid:has(> :nth-child(3):last-child) { /* ... */ }
/* Avoid: nested :has() with broad selectors */
.page:has(.grid:has(> :nth-child(4))) { /* slower */ }
/* Better: scope tightly */
.grid:has(> :nth-child(4)) {
/* Style the grid directly, not a distant ancestor */
grid-template-columns: repeat(2, 1fr);
}
Keep quantity queries at the component level. If you need the parent page to know about child counts, consider a CSS custom property set on the grid and inherited upward via @property registration.