EmpireUI
Get Pro
← Blog8 min read#empty state#illustration#react

Empty State With Illustrations in React: SVG, Lottie and CSS Art

Build empty states that feel designed — not forgotten. SVG inline art, Lottie animations, and pure-CSS illustration techniques for React UIs.

Colorful abstract geometric illustration representing empty UI states in React

Why Empty States Actually Matter

Most teams ship empty states last. The data table's loading, the error boundary's wired up, the happy path looks great — and then someone screenshots an empty inbox and everyone pretends not to notice the bleak white rectangle with the word 'No results.' That's a real UX failure, and it's boring.

Honestly, an empty state is one of the highest-leverage pixels in your app. New users hit it constantly during onboarding. Filters gone wrong? Empty state. First login, no data yet? Empty state. A well-crafted illustration there does actual conversion work, not just decoration.

The difference between a forgettable empty state and a memorable one is usually 200–300 lines of code and an afternoon. That's it. You don't need a design system overhaul — you need an SVG, a clear CTA, and a component you can drop anywhere. Let's build that.

Worth noting: if your app already uses a style like glassmorphism or neobrutalism, your empty state illustration should match. Dropping a pastel 2018-era SVG blob into a cyberpunk dashboard is the UX equivalent of Comic Sans on a legal brief.

Inline SVG: The Most Controllable Option

Inline SVG is almost always the right default. You get full CSS access, can drive colors from CSS variables, animate individual paths, and there's zero extra HTTP round-trip. An <img> pointing at an SVG file loses all of that — you can't change the stroke color on hover, you can't animate a path with a CSS keyframe, nothing.

Here's a minimal but real pattern — a React component that renders an inline 'empty box' SVG and wires the fill to a CSS custom property so it adapts to dark mode automatically: ``tsx // EmptyState.tsx type EmptyStateProps = { title: string description?: string action?: React.ReactNode } export function EmptyState({ title, description, action }: EmptyStateProps) { return ( <div className="flex flex-col items-center gap-4 py-16 text-center"> <svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" > {/* box body */} <rect x="20" y="40" width="80" height="60" rx="6" fill="var(--empty-fill, #e5e7eb)" stroke="var(--empty-stroke, #9ca3af)" strokeWidth="2" /> {/* box flaps */} <path d="M20 40 L60 62 L100 40" stroke="var(--empty-stroke, #9ca3af)" strokeWidth="2" fill="none" /> {/* sparkle */} <circle cx="85" cy="30" r="4" fill="var(--empty-accent, #a78bfa)" /> <circle cx="95" cy="22" r="2.5" fill="var(--empty-accent, #a78bfa)" opacity="0.6" /> <circle cx="78" cy="20" r="2" fill="var(--empty-accent, #a78bfa)" opacity="0.4" /> </svg> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h3> {description && ( <p className="max-w-xs text-sm text-gray-500 dark:text-gray-400">{description}</p> )} {action} </div> ) } ``

The var(--empty-fill) pattern is key. You define --empty-fill at :root or in a .dark class selector and every instance of this component automatically adapts. No prop drilling, no isDark boolean, no runtime theme lookup. Just CSS doing what CSS does.

In practice, most SVG illustrations you'll grab from Figma or open-source icon sets export with hardcoded hex values. Take 10 minutes and swap those to custom properties — it pays for itself the first time you do a rebrand or add dark mode.

One more thing — keep your viewBox consistent across all empty state illustrations in the same app. If one is 0 0 200 200 and another is 0 0 120 120, they'll render at different visual weights even when constrained to the same width. Pick one. I like 0 0 200 200 with a 160px render size.

Lottie Animations: When Static Feels Flat

Sometimes an SVG illustration just doesn't have enough life. A 'no notifications' state that subtly animates — the bell sways, a small envelope drifts in, the checkmark draws on — communicates 'this app is thoughtful' in a way a frozen image can't. That's where Lottie comes in.

Lottie files are JSON that describe After Effects animations, and lottie-react (v2.4+ as of writing) gives you a clean React component wrapper. The bundle hit is real — about 60 kB gzipped for the player — so be deliberate about where you use it: ``bash npm install lottie-react ` `tsx // AnimatedEmptyState.tsx import Lottie from 'lottie-react' import emptyBoxAnimation from './animations/empty-box.json' export function AnimatedEmptyState() { return ( <div className="flex flex-col items-center gap-3 py-12"> <Lottie animationData={emptyBoxAnimation} loop={true} style={{ width: 160, height: 160 }} aria-label="Empty inbox illustration" /> <p className="text-sm text-gray-500">Your inbox is empty</p> </div> ) } ``

Quick aside: if you're importing Lottie JSON directly from your bundler, make sure your tsconfig or bundler config allows JSON imports. In Next.js 14+, you can also fetch() the JSON at runtime and pass it to animationData — that lets you lazy-load the file only when the empty state is actually visible, which is the right call for infrequent states.

LottieFiles has a free library with hundreds of empty-state-appropriate animations. Look for ones under 50 kB — a lot of them are bloated with redundant keyframes. You can trim those with the LottieFiles editor before downloading. I've gone from 180 kB down to 34 kB on a single file that way.

If you're already pulling in Lottie for animated backgrounds, this pattern should feel familiar. The difference in context is intent — background animations are ambient, empty state animations are communicative. Keep empty state loops slow (3–5 second cycle) so they don't feel anxious.

Pure CSS Art: No Assets Required

Here's the underrated option nobody uses enough: CSS art. You can build a surprisingly good empty state illustration using nothing but div elements and CSS — no SVG, no external file, no JSON. It ships at zero bytes beyond what you'd already have.

This works especially well for geometric or abstract states. A 'no search results' magnifying glass? About 30 lines of CSS. A 'nothing here yet' empty folder? Doable. The trick is layering pseudo-elements and border-radius creatively: ``tsx // CSSEmptyState.tsx — 'nothing found' magnifying glass export function SearchEmptyState() { return ( <div className="flex flex-col items-center gap-4 py-16"> <div className="relative w-24 h-24"> {/* Circle lens */} <div className="absolute inset-0 rounded-full border-4 border-gray-300 dark:border-gray-600" style={{ width: 72, height: 72 }} /> {/* Handle */} <div className="absolute bg-gray-300 dark:bg-gray-600 rounded-full" style={{ width: 8, height: 28, bottom: 0, right: 4, transform: 'rotate(-45deg)', transformOrigin: 'top center', }} /> </div> <p className="text-sm font-medium text-gray-600 dark:text-gray-400"> No results for that search </p> </div> ) } ``

Honestly, pure CSS art isn't going to win any illustration awards. But for utility-focused apps, a clean geometric shape reads more 'designed' than a stock SVG from a library everyone's already seen. And it stays perfectly crisp at any DPI — no 2x asset to manage.

Worth noting: if your app already has a strong visual style — say, you're working with claymorphism components or the aurora aesthetic — CSS art can actually harmonize better than an imported illustration that has its own visual language baked in.

The limitation is complexity. Once you need more than a basic shape or two, SVG is genuinely easier to maintain. Don't fight CSS trying to draw a raccoon. Draw geometric objects in CSS, characters in SVG, and anything with 60fps physics in Lottie. That's the mental model.

Structuring a Reusable EmptyState Component

Okay, you've got your illustration approach decided. Now don't scatter empty states all over the codebase with ad-hoc styling. Build one component and give it a slot-based API. Here's a pattern that scales across all three illustration methods:

// components/EmptyState/index.tsx
import { ReactNode } from 'react'

type IllustrationType = 'svg' | 'lottie' | 'css' | 'custom'

interface EmptyStateProps {
  illustration?: ReactNode        // pass whatever you want here
  title: string
  description?: string
  action?: ReactNode
  size?: 'sm' | 'md' | 'lg'
}

const paddingMap = { sm: 'py-8', md: 'py-16', lg: 'py-24' }
const illustrationSizeMap = { sm: 80, md: 120, lg: 160 }

export function EmptyState({
  illustration,
  title,
  description,
  action,
  size = 'md',
}: EmptyStateProps) {
  return (
    <div
      className={`flex flex-col items-center gap-4 text-center ${
        paddingMap[size]
      }`}
      role="status"
      aria-label={title}
    >
      {illustration && (
        <div style={{ width: illustrationSizeMap[size], height: illustrationSizeMap[size] }}>
          {illustration}
        </div>
      )}
      <div className="space-y-1">
        <h3 className="text-base font-semibold text-gray-900 dark:text-white">
          {title}
        </h3>
        {description && (
          <p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mx-auto">
            {description}
          </p>
        )}
      </div>
      {action && <div>{action}</div>}
    </div>
  )
}

The role="status" attribute is easy to miss and most people skip it. Screen readers announce status changes, so when your data loads and then the empty state appears, users who rely on assistive tech know something happened. Takes one second to add, makes a real difference.

That said, don't over-engineer the props. Every time I've seen an EmptyState component with a variant enum and a colorScheme and a iconPosition prop, it ends up with 6 stories in Storybook and zero adoption because nobody can remember how it works. Keep it dumb — accept a ReactNode for the illustration and let each callsite decide.

You can pair this with the gradient generator to produce a background gradient for the empty state container, or add a frosted glass effect using the glassmorphism generator if your UI calls for it. The component stays the same — you're just styling the wrapper div differently per context.

Animation Polish: Motion That Doesn't Annoy

A subtle entrance animation on your empty state makes it feel intentional rather than fallback. The rule is: animate once, then stop. An empty state that keeps bouncing or pulsing every few seconds is genuinely annoying — like a loading spinner that never goes away.

Tailwind makes this trivial with animate-fade-in or a custom keyframe. Here's a CSS-only entrance that fades and slides up 8px: ``css @keyframes emptyStateIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } .empty-state-enter { animation: emptyStateIn 0.3s ease-out both; } ` Slap that class on the root div of your EmptyState` component and you're done. 0.3s is the sweet spot — fast enough to not block the user, long enough that it reads as intentional.

If you're using Framer Motion already in your project, a motion.div with initial={{ opacity: 0, y: 8 }} and animate={{ opacity: 1, y: 0 }} reads cleaner and integrates with your existing layout animations. Don't add Framer Motion just for this though — the CSS approach is fine and costs nothing.

One thing worth respecting: prefers-reduced-motion. Wrap any entrance animation in a media query or use the useReducedMotion() hook from Framer. Some users have vestibular disorders and constant motion is actively harmful. Two lines of code, non-negotiable.

Quick aside: if you're building empty states in a page transition context, the entrance animation from the page transition might already handle it. Check before you add a second layer of animation — stacked opacity transitions from two different systems look janky.

Testing and Accessibility Checklist

Before you ship your empty state component, run through this. It's short, but teams skip it constantly. Can a keyboard user reach the CTA button inside the empty state? Tab to it and verify. Does VoiceOver or NVDA announce anything useful when the empty state appears? The role="status" helps, but test it.

For automated testing, the empty state is one of the easiest things to unit-test because it's pure rendering logic. Here's a minimal test with React Testing Library: ``tsx import { render, screen } from '@testing-library/react' import { EmptyState } from './EmptyState' test('renders title and description', () => { render( <EmptyState title="No messages yet" description="Start a conversation to see messages here" /> ) expect(screen.getByText('No messages yet')).toBeInTheDocument() expect(screen.getByText(/start a conversation/i)).toBeInTheDocument() }) test('does not render description when omitted', () => { render(<EmptyState title="Nothing here" />) expect(screen.queryByRole('paragraph')).not.toBeInTheDocument() }) ``

SVG accessibility is its own rabbit hole. If your SVG is purely decorative, aria-hidden="true" on the <svg> element is correct — don't add a <title> to a decorative illustration, it just creates noise for screen readers. If the SVG conveys meaning that isn't captured elsewhere in the DOM, add role="img" and a <title> as the first child of the <svg>.

Look, the accessibility work here is maybe 45 minutes total across the whole component. Most of it is things you'd do for any interactive component. The payoff is a component that works for everyone and doesn't fail an audit in 2027 when someone actually runs axe on your app.

If you want more visual polish, browse components in the Empire UI library — there are card, panel, and animation primitives that pair well with what we've built here. You don't have to rebuild the full design system, just drop in what you need.

FAQ

Should I use an SVG file or inline SVG for empty state illustrations?

Inline SVG almost always. You lose CSS variable control and animation access the moment it's behind an <img> tag. The only reason to use a file is if the SVG is huge and you want to lazy-load it.

How big should an empty state illustration be?

120px–160px render size covers most cases. Go smaller (80px) in compact sidebars or tables. Going larger than 200px usually just pushes your CTA button off-screen on mobile.

Is Lottie worth the bundle size for empty states?

Only if you're already using Lottie elsewhere. The ~60 kB gzipped hit is hard to justify for one animation. If empty states are the only use case, stick with a CSS animation or an animated SVG.

Do I need a different empty state for every screen in my app?

No — one well-designed base component with a title, description, and optional illustration slot handles 90% of cases. Customize the copy per context, not the whole component.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Stepper Component in React: Multi-Step Forms and OnboardingSkeleton Loader in React: Pulse Animation and Smart Loading StatesEmpty State Design in Tailwind: Illustrations, CTAs and Skeleton FallbackGlassmorphism Onboarding UI: Multi-Step Wizard With Frosted Steps