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 () in the Mastering Container Queries & Responsive Layouts guide.

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 markup is the state Four panels. Loading: the panel has aria-busy true, and skeleton rows are shown. Empty: the list has no li elements, and an empty-state message is shown. Error: the panel contains an element with role alert, and the error layout is shown. Populated: the list has items, and they are shown normally. Each panel lists the selector that detects its state. Four states, zero state classes Loading Empty Error Populated "No results" "Could not load" [aria-busy=true] :not(:has(li)) :has([role=alert]) :has(li) Script renders content and ARIA; CSS reads them.

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.

Live demoOne component, four content-derived states
The same markup and CSS rendered four times. Only the content differs: aria-busy, an empty list, an alert, or list items. No state classes are used. Drag the bottom-right corner to resize the frame in either direction.
<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.

Shared state for CSS and assistive technology Two rows. Row one: the attribute aria-busy true feeds two arrows, one to screen reader behaviour, which waits before announcing, and one to CSS, which shows skeletons. Row two: an element with role alert feeds screen reader behaviour, which announces immediately, and CSS, which shows the error layout. One source of truth, two readers aria-busy="true" role="alert" screen reader waits CSS: skeletons announced at once CSS: error layout never out of sync

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. () Selector Performance explains what engines do and which patterns to avoid.

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 match my element?: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 () react to aria-busy for loading states? Yes. A container can style itself with :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 () fast enough for frequently changing states? For scoped selectors anchored to a component, yes. Avoid broad selectors like 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 articles

More pages in the same section.