Empty and Loading States With ()
Every list, table and search result area has at least three states beyond "full of content": loading, empty and failed. They are usually wired up with classes — script adds is-loading, removes it, adds is-empty — and every one of those toggles is a chance for the visual state to drift from the real one. The markup already knows the truth: a list with no <li> is empty, a region with aria-busy="true" is loading, a form with an invalid field has an error. :has() lets the container read that truth directly, and :empty handles the simplest case. This page builds a results panel whose empty, loading and error layouts come from its own content. It belongs to Parent-Aware Layouts With
Deriving state from content
The idea is to let the DOM that script already produces be the only source of state. Script renders results into a list, sets aria-busy while fetching, and inserts an error message on failure — all things it must do anyway for the content and for assistive technology. CSS then chooses the layout from what is present.
The complete implementation
The demo renders the same component four times with different content — busy, empty, failed and populated — and the one stylesheet below produces all four layouts. Nothing in the markup names the state; each panel just contains what a real data fetch would leave behind.
<section class="results" aria-labelledby="results-title" aria-busy="false">
<h2 id="results-title">Results</h2>
<ul class="results__list">
<!-- items rendered here -->
</ul>
<p class="results__empty">No results. Try a broader search.</p>
<div class="results__skeleton" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<!-- On failure, script inserts: <p role="alert">Could not load results.</p> -->
</section>
/* Hidden unless their state applies. */
.results__empty,
.results__skeleton { display: none; }
/* Empty: not loading, no items, no error. */
.results:not([aria-busy="true"]):not(:has(li, [role="alert"])) .results__empty {
display: block;
}
/* Loading: show skeletons, hide stale content. */
.results[aria-busy="true"] .results__skeleton { display: grid; }
.results[aria-busy="true"] .results__list { display: none; }
/* Error: tint the panel and hide the list. */
.results:has([role="alert"]) {
border-color: #dc2626;
background: #fef2f2;
}
.results:has([role="alert"]) .results__list { display: none; }
/* Skeleton shimmer, removed for reduced motion. */
.results__skeleton span {
display: block;
block-size: 1rem;
border-radius: 4px;
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%) 0 0 / 200% 100%;
animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer { to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) {
.results__skeleton span { animation: none; }
}
The empty-state selector is the one to read carefully. It matches only when the panel is not loading and contains neither an item nor an alert. Without the loading check, the "No results" message would flash during every fetch, because the list is briefly empty while data is on its way. The order of the checks does not matter to the browser, but writing the loading guard first makes the intent obvious to the next person who edits it.
The state before the first fetch
One trap is the moment between page load and the first request. If the server sends the panel with an empty list and aria-busy="false", the empty-state message appears immediately — telling the user there are no results before anything has been searched. Two fixes work. Render the panel with aria-busy="true" in the initial HTML when data will load straight away, so the skeleton shows first. Or, for search pages that wait for user input, keep the list out of the DOM until the first search, and extend the empty selector to require the list: .results:has(.results__list):not(:has(li)). Either way, the markup continues to describe the real state, and the CSS continues to read it.
Smoothing state changes
Switching between these layouts is a change of display, which cannot be transitioned directly. For the content that appears, @starting-style gives a gentle entrance: .results__list li { transition: opacity 200ms ease-out; } @starting-style { .results__list li { opacity: 0; } } fades newly rendered items in, including every item that appears after a fetch. Keep it short, and skip it entirely under prefers-reduced-motion: reduce. Resist animating the empty-state message or the error layout in: those states should appear promptly and without delay, because they carry information the user is waiting for.
Beyond lists: tables, grids and slots
The pattern applies to any container whose emptiness is structural. A table can reveal a placeholder row kept in its footer with table:not(:has(tbody tr)) .empty-row { display: table-row; }. A dashboard grid can collapse a widget whose chart has not rendered with .widget:not(:has(svg, canvas)). A notification tray can hide its header when it contains no notifications. In each case, ask what the DOM looks like in the state you want to style, and write the selector that describes exactly that shape. Keep a comment next to each such selector naming the state it detects — /* empty: loaded, no rows */ — because a selector built from negations is easy to misread later. When the same state shape appears in several components, consider a shared utility such as .has-empty-state, applied once to each container, so the logic lives in one rule and every component gets the same behaviour, including the loading guard that keeps the empty message from flashing during a fetch.
The key technique: prefer semantic hooks to presentational ones
aria-busy and role="alert" are not arbitrary. Screen readers use aria-busy to hold announcements until a region finishes updating, and they announce role="alert" content immediately. By styling from those attributes, the visual state and the accessible state can never disagree: if the skeleton is showing, assistive technology knows the region is busy; if the error layout is showing, the error was announced.
The same principle extends further. A form section can style itself from :has(:user-invalid); a disclosure can style from [aria-expanded="true"]; a table can show a "select all" bar from :has(input:checked). Each time, the attribute that assistive technology depends on is also the styling hook.
Where fits
:empty matches an element with no children at all — no elements, no text, no comments aside. It is the lightest way to hide an empty container, for example an error slot that is only sometimes filled: .error-slot:empty { display: none; }. Its weakness is whitespace. Templates often leave a newline or spaces between the opening and closing tags, and that whitespace is a text node that stops :empty from matching. Selectors Level 4 proposes ignoring whitespace-only text, but browsers have not broadly adopted it, so do not rely on it. When the markup comes from a template engine, :not(:has(*)) is more robust for "no child elements", though it ignores text content entirely. Pick the selector that matches how the content arrives.
Scoping () for performance
Browsers optimise :has() well, but the cost depends on how widely a selector has to be re-evaluated when the DOM changes. .results:has(li) is cheap: only mutations inside a .results element can affect it. body:has(.results li) is more expensive, because the browser must consider changes anywhere under body. Anchor :has() to the component that owns the state, as the implementation does.
Browser support
:has() is supported in Chrome and Edge 105+, Firefox 121+ and Safari 15.4+. :user-invalid, mentioned above, is supported in Chrome and Edge 119+, Firefox 88+ and Safari 16.5+. :empty is supported in every current browser, with the whitespace nuance described above, and prefers-reduced-motion in Chrome 74+, Edge 79+, Firefox 63+ and Safari 10.1+. In browsers without :has(), the empty and error rules do not apply; keep the error message itself visible by default so failures are never hidden.
FAQ
How do I show an empty-state message when a list has no items?
Place the message next to the list and show it with a selector such as .results:not(:has(li)) .empty-state. When items are rendered into the list, the selector stops matching and the message hides, with no class toggling in script.
Why doesn't :empty only matches elements with no children at all, including text nodes. Whitespace between the tags counts as a text node, and although Selectors Level 4 proposes ignoring whitespace, browsers have not broadly adopted that change, so template whitespace often stops :empty from matching.
Can :has([aria-busy='true']) to show skeletons or dim content while a region loads. Using the ARIA attribute ties the visual state to the state assistive technology already announces.
Is body:has(...) that must be rechecked on every DOM change anywhere in the page; anchor the :has() to the nearest container that owns the state.
Related
- Parent-Aware Layouts With
() — the parent guide. - Form Validation Layouts With
() — error states in forms. - Content-Aware Card Layouts With
() — layout from optional content. - CSS-Only Loading Spinners and Skeletons — the skeleton animation.
Related articles
More pages in the same section.