GODRICH

9 Empty State UI Design Cards Drawn in Pure CSS

Empty state UI design fills the space a list would occupy when there is no data to put in it.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine are ordered by why the screen is empty, not by how they look. The first three are cases where the reader did nothing wrong: nothing exists yet (01), nothing matches the query (02), and nothing has been added even though this is the place to add it (03). The middle three are cases the reader can undo: the connection dropped (04), the cart holds nothing (05), and too many filters are stacked (06). The last three are the system's answer: the address is wrong (07), access is denied (08), and in 09 empty was the goal all along. Every card has a button or a field that actually works, and every line that changes carries aria-live="polite", so the new sentence reaches people who cannot see the screen. The icons are Phosphor Duotone SVGs pasted straight into the markup, so nothing is fetched at runtime.

01Inbox-zero floating card

The tray icon rises 14px and settles back while the oval shadow beneath it narrows on the same beat. Shrinking the shadow is what sells the lift — move the icon alone and it reads as a shake, not a float.

translateYscaleXaria-live
.ez.is-demo .ez__icon { animation: ez-float $duration $easing infinite; }
.ez.is-demo .ez__shadow { animation: ez-cast $duration $easing infinite; }
@keyframes ez-float {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-14px); }
}
@keyframes ez-cast {
  0%, 100% { transform: scaleX(1); opacity: .18; }
  50%      { transform: scaleX(.68); opacity: .1; }
}

02Sweeping no-results lens

Typing in the field actually filters the three rows below it, and the magnifier card covers the panel only when nothing survives. The lens tilts as it travels, so it reads as still searching rather than parked.

translateXrotatehidden
function render() {
  var v = q.value.trim();
  var hit = 0;
  rows.forEach(function (r) {
    var on = !v || r.dataset.name.indexOf(v) >= 0;
    r.hidden = !on;
    if (on) hit += 1;
  });
  empty.hidden = hit > 0;
  msg.textContent = v ? msg.dataset.hit.replace('{q}', v) : NONE;
}

03Dashed dropzone with a falling file

The file drops into the dashed box, the cloud below swells to 1.1 at 62%, and the file vanishes between 70% and 70.1% — one frame, no fade. Cross-fading two layers instead would leave frames where both are half-transparent, which is exactly the ghosting this cut avoids.

translateY@keyframes dz-falldragover
@keyframes dz-fall {
  0%   { transform: translateY(-28px) scale(.82); opacity: 0; }
  16%  { transform: translateY(-28px) scale(.82); opacity: 1; }
  62%  { transform: translateY(30px) scale(1); opacity: 1; }
  70%  { transform: translateY(26px) scale(1); opacity: 1; }
  70.1%, 100% { transform: translateY(-28px) scale(.82); opacity: 0; }
}

04Offline card with a turning retry arrow

The arrow turns from 0deg to 360deg in one direction, so the last frame draws the same picture as the first and there is no rewind to hide. Press the button for real and it locks for 900ms while the attempt count climbs in the status line.

rotatelineararia-live
.of.is-demo .of__arrow,
.of.is-trying .of__arrow { animation: of-turn $duration $easing infinite; }
@keyframes of-turn {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}

05Empty cart that rolls in

The cart rolls in from 74px off the left edge, stops at the 38% mark, tips once, and rolls back out the way it came. Putting transform-origin at 50% 78% — wheel height — makes it pivot on the wheels instead of the basket.

translateXrotatetransform-origin
.ct__cart { transform-origin: 50% 78%; }
.ct.is-demo .ct__cart { animation: ct-roll $duration $easing infinite; }
@keyframes ct-roll {
  0%, 100% { transform: translateX(-74px) rotate(-14deg); }
  38%      { transform: translateX(0) rotate(0deg); }
  52%      { transform: translateX(0) rotate(-9deg); }
  66%      { transform: translateX(0) rotate(0deg); }
}

06Filter chips clearing one by one

Three chips switch off 180ms apart, and steps(1, end) means not a single frame catches one at an in-between size. Clicking a chip's x actually drops that condition, and the remaining count is recalculated.

scaleXsteps(1, end)animation-delay
.fc.is-demo .fc__chip { animation: fc-off $duration steps(1, end) infinite; }
.fc.is-demo li:nth-child(2) .fc__chip { animation-delay: 180ms; }
.fc.is-demo li:nth-child(3) .fc__chip { animation-delay: 360ms; }
@keyframes fc-off {
  0%, 32%   { opacity: 1; transform: scaleX(1); }
  33%, 74%  { opacity: 0; transform: scaleX(.2); }
  75%, 100% { opacity: 1; transform: scaleX(1); }
}

07404 card with a wandering needle

The compass swings through -26deg, 22deg, -14deg, and 10deg, narrowing each time. A full turn would read as "found it," and an even swing would read as a metronome, so the shrinking arc came closest to reading as lost.

rotateborder: dashedease-out
.nf.is-demo .nf__icon { animation: nf-wander $duration $easing infinite; }
@keyframes nf-wander {
  0%, 100% { transform: rotate(-26deg); }
  28%      { transform: rotate(22deg); }
  54%      { transform: rotate(-14deg); }
  78%      { transform: rotate(10deg); }
}

08Permission lock with a padlock that bounces once

The rows behind the lock are never removed — their blur only deepens from 3px to 6px and comes back. That is how one picture says "hidden" rather than "missing," and the rows need different widths (78% and 56%) or they look like bars instead of covered text.

scalefilter: bluraria-live
.pm__rows { filter: blur(3px); }
.pm.is-demo .pm__rows { animation: pm-veil $duration ease-in-out infinite; }
@keyframes pm-veil {
  0%, 100% { filter: blur(3px); opacity: 1; }
  50%      { filter: blur(6px); opacity: .6; }
}

09All-done card with rising confetti

Six pieces start 240ms apart, so a new one is already halfway up as the one before it fades. Giving each piece its own --x and --r means a single keyframe block scatters them six different ways.

translateanimation-delaycustom property
.ad.is-demo .ad__bit:nth-child(1) { animation-delay: 0ms;   --x: -46px; --r: 180deg; }
.ad.is-demo .ad__bit:nth-child(2) { animation-delay: 240ms; --x: -26px; --r: -150deg; }
@keyframes ad-pop {
  0%   { transform: translate(var(--x, 0), 0) rotate(0deg) scale(.6); opacity: 0; }
  16%  { opacity: 1; }
  70%  { opacity: 1; }
  100% { transform: translate(var(--x, 0), -58px) rotate(var(--r, 180deg)) scale(1); opacity: 0; }
}

Where it breaks — the trap

An empty state almost always lands in the slot a list just vacated, which means the code that hides rows and the code that shows the card run in the same tick. That is exactly where 02 broke. The script switched the unmatched rows off correctly with r.hidden = !on, but the stylesheet said .sw__row { display: flex; }. The display: none a browser grants [hidden] lives in the UA stylesheet, so it loses to any author declaration — including that one line. The result was three rows still on screen with a half-transparent "No documents found" card sitting on top of them. The render metrics passed because the motion itself was fine. It surfaced only when someone opened the first poster and looked at it. The fix is one line, .sw__row[hidden] { display: none; }, and every part that sets display needs its own copy.

The second trap is switching off your own autoplay loop. Inside 02's render() sat card.classList.remove('is-demo'), and that function also ran once at startup. Nobody had touched anything, yet the grid loop was already dead: the measurement came back as 0.00% swept area, 0.0 intensity, and 0 animations. The line that cancels the loop belongs in the input handler, not in render(). The finished sources for all nine, plus the React ports, are in a zip that opens with the password ydp2zjcw, and both of those lines are already corrected in it.

Symptom Cause Fix
Filtered rows stay visible .row { display: flex } beats the UA rule for [hidden] add .row[hidden] { display: none }
Nothing moves in the grid the init function strips .is-demo cancel the loop in the event handler only
State change goes unheard only the text was swapped put aria-live="polite" on that line

Accessibility (reduced-motion)

Under prefers-reduced-motion: reduce, all nine loops stop, and not one card loses the thing it was there to say. 05 does not leave the cart parked offstage — transform: translateX(0) pins it in the middle — and 08 keeps a flat 3px blur so "covered" still reads. Every line that changes carries aria-live="polite", so when the retry button is pressed, the line that changes to "Checking the connection" is announced rather than silently swapped. MDN's aria-live page explains when each of its three values is read out. The dropzone in 03 carries role="button" and tabindex="0" together, so Enter and Space open the file picker for anyone who is not dragging with a mouse.

Loading indicators that share this slot are collected in the loading category, and admin-screen parts are in the dashboard category.

FAQ

Does an empty state really need an illustration?

If the picture is there for its own sake, skip it. These nine use icons to tell the reasons apart at a glance, not to decorate: a padlock, a slashed Wi-Fi symbol, and an empty cart are already three different situations before a word is read. When a screen can only be empty for one reason, a sentence and a button are enough.

Are icons alone enough, with no image files?

All nine ship with zero image files. The Phosphor Duotone SVGs are pasted into the HTML, so no extra request goes out and a single color declaration retints them for each stage. Keep the SVG at 1em and let clamp() drive the wrapper's font-size, and the whole thing scales down proportionally on a phone.

Can the same card serve as both the empty state and the loading state?

It shouldn't. Loading means "something is on its way" and empty means "there is nothing coming," so one shared card leaves the reader unsure whether to wait or to act. 04 sits right on that line: the list is empty, but the cause is a failed request rather than absent data, so both the sentence and the button differ.

Enter the archive password

The password is inside this article. You will find it as you read.