GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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. Unlike most empty state ui examples, these use Phosphor Duotone SVGs pasted straight into the markup, so nothing is fetched at runtime. Screens that fold because they overflow, not empty, continue into Accordion List UI Patterns, and the moment an empty shelf starts filling up belongs to File Tree CSS Views.

01Inbox-zero floating card

What this empty state design shows

The tray icon rises 14px and settles back while the oval shadow beneath it narrows on the same beat.

Inbox-zero floating card
Open in a new tab

01 · Inbox-zero floating card — Measured area 1.28% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateYscaleXaria-live

Where to look in the code

Shrinking the shadow is what sells the lift — move the icon alone and it reads as a shake, not a float.

.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

What happens on screen

Typing in the field actually filters the three rows below it, and the magnifier card covers the panel only when nothing survives.

Sweeping no-results lens
Open in a new tab

02 · Sweeping no-results lens — Measured area 2.691% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXrotatehidden

How to read the code

The lens tilts as it travels, so it reads as still searching rather than parked.

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

What moves

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.

Dashed dropzone with a falling file
Open in a new tab

03 · Dashed dropzone with a falling file — Measured area 0.929% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateY@keyframes dz-falldragover

What to change

Cross-fading two layers instead would leave frames where both are half-transparent, which is exactly the ghosting this cut avoids.

@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

What you see

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.

Offline card with a turning retry arrow
Open in a new tab

04 · Offline card with a turning retry arrow — Measured area 0.574% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotatelineararia-live

Where to look in the code

Press the button for real and it locks for 900ms while the attempt count climbs in the status line.

.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

What happens on screen

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.

Empty cart that rolls in
Open in a new tab

05 · Empty cart that rolls in — Measured area 2.624% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXrotatetransform-origin

How to read the code

Putting transform-origin at 50% 78% — wheel height — makes it pivot on the wheels instead of the basket.

.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

What moves

Three chips switch off 180ms apart, and steps(1, end) means not a single frame catches one at an in-between size.

Filter chips clearing one by one
Open in a new tab

06 · Filter chips clearing one by one — Measured area 4.229% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleXsteps(1, end)animation-delay

What to change

Clicking a chip's x actually drops that condition, and the remaining count is recalculated.

.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

What you see

The compass swings through -26deg, 22deg, -14deg, and 10deg, narrowing each time.

404 card with a wandering needle
Open in a new tab

07 · 404 card with a wandering needle — Measured area 0.709% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotateborder: dashedease-out

Where to look in the code

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.

.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

What happens on screen

The rows behind the lock are never removed — their blur only deepens from 3px to 6px and comes back.

Permission lock with a padlock that bounces once
Open in a new tab

08 · Permission lock with a padlock that bounces once — Measured area 0.776% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

scalefilter: bluraria-live

How to read the code

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.

.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

What moves

Six pieces start 240ms apart, so a new one is already halfway up as the one before it fades.

All-done card with rising confetti
Open in a new tab

09 · All-done card with rising confetti — Measured area 1.54% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateanimation-delaycustom property

What to change

Giving each piece its own --x and --r means a single keyframe block scatters them six different ways.

.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(). Filling those cards with drawn art reuses the no-photo techniques of Image Zoom Viewer Interactions. 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 empty state ux design 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. When the empty reason has to be explained finger-first on a counter screen, Kiosk UI Design sets that tone.

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.