Empty State Design in Tailwind: Illustrations, CTAs and Skeleton Fallback
Empty states make or break first impressions. Learn to build illustration-first empty states, smart CTAs, and skeleton loaders with Tailwind CSS and React.
Why Empty States Are Harder Than They Look
Most developers treat empty states as an afterthought — slap a No results found string in gray, ship it, done. That's a mistake you'll feel in your metrics. Empty states are the first thing a brand-new user sees, the screen that greets someone after they delete their last item, the view that decides whether a confused user bounces or stays.
Honestly, a bad empty state can undo a beautiful onboarding flow in about 3 seconds. You've done all the hard work getting someone to sign up, and then you drop them into a hollow white void with a tiny muted paragraph. They close the tab.
The good news: fixing this is 90% a design decision and 10% implementation. Tailwind makes the implementation stupidly fast. Once you understand what an empty state actually needs — a visual anchor, a clear explanation, and one action — you can scaffold a solid one in under 30 minutes.
Worth noting: the pattern has three distinct variants, and they're not interchangeable. There's the *zero-data* state (user has never added content), the *no-results* state (search or filter returned nothing), and the *loading/skeleton* fallback (data is in flight). Each needs different copy, different visuals, and sometimes a different CTA entirely.
Anatomy of a Good Empty State
Every solid empty state has three layers: a visual anchor (illustration or icon), contextual copy (heading + subtext), and a primary action. Cut any one of them and you've got a half-baked empty state. The visual anchor stops the user's eye and signals this is intentional, not broken. The copy explains exactly what's missing and why. The CTA tells them what to do next.
The illustration doesn't have to be a full SVG masterpiece. Even a 48px icon in a rounded container with a light background color does the job. What matters is that the visual matches the *context*: an inbox empty state should feel different from an empty product catalog. Generic clip-art reads as lazy.
Copy is where most teams stumble. No data available is technically accurate and completely useless. Write like you're talking to a person: You haven't added any projects yet is better. Your projects will show up here — start by creating one is better still. Keep the heading under 8 words and the subtext under 20.
One more thing — the CTA should be a button, not a link. If you want someone to take action, give them something that *looks* clickable. Tailwind's btn patterns are fine here; the visual weight of a solid button converts better than an underlined anchor in this specific context.
Building a Zero-Data Empty State with Tailwind
Here's a production-ready empty state component. It's typed, composable, and needs zero extra libraries — just Tailwind CSS 3.4+ and React 18.
// EmptyState.tsx
interface EmptyStateProps {
icon: React.ReactNode;
heading: string;
subtext: string;
action?: {
label: string;
onClick: () => void;
};
}
export function EmptyState({ icon, heading, subtext, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-20 px-6 text-center">
{/* Icon container */}
<div className="w-16 h-16 rounded-2xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-6 text-gray-400 dark:text-gray-500">
{icon}
</div>
{/* Copy */}
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{heading}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-xs mb-8">
{subtext}
</p>
{/* CTA */}
{action && (
<button
onClick={action.onClick}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium transition-colors"
>
{action.label}
</button>
)}
</div>
);
}The 48px icon container (w-16 h-16 = 64px in Tailwind's default 4px scale) is a sweet spot. Smaller and it feels like a checkbox; bigger and it competes with the heading. If you're using Heroicons, render them at size={24} inside that container so there's breathing room all around.
In practice, you'll want two size variants of this component: a full-page version for routes that genuinely have no content, and a compact inline variant for widgets, sidebars, and cards. The full-page one should be vertically centered in the viewport; the inline one just needs py-10 and can sit in the natural document flow.
Illustration-First Empty States That Actually Work
If your product has a visual identity worth showing off, an illustration beats an icon every single time. Not a stock photo — an illustration. The distinction matters because illustrations feel crafted for this moment, while stock photos feel grabbed off Unsplash (even when they are grabbed off Unsplash).
The practical question is dimensions. Most empty state illustrations sit at 200–240px wide with a natural aspect ratio around 4:3 or 1:1. Don't stretch SVGs beyond their design size — Tailwind's max-w-[240px] w-full gives you that responsive constraint cleanly without a hardcoded pixel value breaking on narrow viewports.
// IllustrationEmptyState.tsx
function NoProjectsIllustration() {
// Inline SVG keeps it zero-dep and theme-aware
return (
<svg
viewBox="0 0 240 200"
className="w-full max-w-[240px] text-indigo-400"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Folder base */}
<rect x="20" y="80" width="200" height="100" rx="12" fill="currentColor" opacity="0.15" />
<rect x="20" y="60" width="80" height="30" rx="8 8 0 0" fill="currentColor" opacity="0.25" />
{/* Dashed center line */}
<line
x1="90" y1="130" x2="150" y2="130"
stroke="currentColor"
strokeWidth="2"
strokeDasharray="6 4"
opacity="0.5"
/>
{/* Plus icon */}
<circle cx="120" cy="130" r="18" fill="currentColor" opacity="0.2" />
<line x1="120" y1="122" x2="120" y2="138" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
<line x1="112" y1="130" x2="128" y2="130" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
);
}Using currentColor in your SVG paths means the illustration inherits whatever text color is set on its parent — swap the parent's text-indigo-400 to text-emerald-400 and the whole illustration re-themes. That's the kind of thing that looks obvious in hindsight but saves you duplicating SVG files per theme.
Quick aside: if you're building something visually expressive — a creative tool, a SaaS with personality — consider pairing your empty states with a matching visual language. Empire UI's glassmorphism components and vaporwave and aurora style hubs all have the kind of aesthetic backbone that makes custom empty-state illustrations feel *at home* rather than bolted on.
No-Results States: Different Problem, Different Pattern
A zero-data state and a no-results state are not the same. Zero-data means nothing exists yet. No-results means something exists, but the user's query didn't match it. Conflating them confuses users who *know* they have data and assume the app is broken.
The no-results empty state needs to show the query that returned nothing, suggest why there might be no match, and give an escape hatch — usually a Clear filters button or a Try a different search nudge. Here's a minimal pattern:
// NoResultsState.tsx
interface NoResultsProps {
query: string;
onClear: () => void;
}
export function NoResultsState({ query, onClear }: NoResultsProps) {
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<div className="w-14 h-14 rounded-full bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-5 text-amber-400">
{/* Magnifying glass with X */}
<svg viewBox="0 0 24 24" className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="10" cy="10" r="7" />
<path d="m15 15 5 5" strokeLinecap="round" />
<path d="m8 8 4 4m0-4-4 4" strokeLinecap="round" />
</svg>
</div>
<h3 className="text-base font-semibold text-gray-900 dark:text-white mb-1">
No results for “{query}”
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
Try adjusting your search or clearing filters.
</p>
<button
onClick={onClear}
className="text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:underline"
>
Clear search
</button>
</div>
);
}Notice the CTA here is a text link, not a solid button. That's intentional — Clear search is a destructive-ish action (it removes the user's query), so a lower-visual-weight control signals lower commitment. If you put a full primary button there, users hesitate.
Look, the real payoff of separating these two states is that your analytics suddenly make sense. Track empty_state_shown with a type property (zero_data vs no_results) and you can tell if users are bouncing because they genuinely have no content, or because your search is failing them. That's the kind of signal that drives roadmap decisions.
Skeleton Loaders: The Third Empty State You're Probably Misusing
Skeleton loaders aren't really an empty state — they're a loading state that *replaces* the empty state during data fetching. The difference matters because showing a skeleton when there's genuinely no data to load creates a confusing flicker: skeleton appears, then vanishes into an empty state. Users feel like something broke.
The rule is simple: show a skeleton only when you're confident data is coming. If you don't know whether the user has any data yet, fetch first, then decide which state to render. React 18's Suspense + use() hook pattern makes this cleaner than the old isLoading boolean juggling.
// SkeletonCard.tsx
export function SkeletonCard() {
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 p-5 space-y-3 animate-pulse">
{/* Avatar + name row */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="space-y-1.5 flex-1">
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-1/3" />
<div className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded w-1/4" />
</div>
</div>
{/* Body lines */}
<div className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded w-full" />
<div className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded w-5/6" />
<div className="h-2.5 bg-gray-200 dark:bg-gray-700 rounded w-4/6" />
</div>
);
}
// Usage: render 3-4 of these while awaiting data
export function SkeletonGrid() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
);
}Tailwind's animate-pulse is a CSS opacity animation — it doesn't repaint, it composites. That's why it feels smooth even on low-end devices. The animation runs at 2 seconds per cycle in Tailwind's default config. If that feels too slow for your design (it does in some dense UIs), you can override it in tailwind.config.js with a custom pulse keyframe at 1.5s.
That said, skeleton design has a trap: matching the skeleton layout to the real content too precisely. You'll inevitably tweak the card layout post-launch and forget to update the skeleton. Keep skeletons approximate — correct column count, approximate row count, ballpark proportions — and you'll save yourself that maintenance headache. Check out the box shadow generator if you want to add subtle shadow depth to your skeleton cards without guessing values by hand.
Putting It All Together: A State Machine Approach
The cleanest way to handle all three states — skeleton, zero-data, no-results — is a small state machine in your data-fetching layer. Not XState-level complexity. Just a discriminated union that makes it impossible to accidentally render the wrong state.
// useListState.ts
type ListState<T> =
| { status: 'loading' }
| { status: 'empty' } // fetched, user has no items
| { status: 'no-results'; query: string } // filtered, nothing matched
| { status: 'data'; items: T[] };
// ProjectList.tsx
export function ProjectList({ query }: { query: string }) {
const state = useProjects(query); // returns ListState<Project>
if (state.status === 'loading') {
return <SkeletonGrid />;
}
if (state.status === 'empty') {
return (
<EmptyState
icon={<FolderIcon className="w-7 h-7" />}
heading="No projects yet"
subtext="Create your first project to get started."
action={{ label: 'New project', onClick: openCreateModal }}
/>
);
}
if (state.status === 'no-results') {
return <NoResultsState query={state.query} onClear={clearQuery} />;
}
return <ProjectGrid items={state.items} />;
}This pattern forces you to handle every case explicitly. TypeScript will yell at you if you add a new status to the union but forget to handle it in the render. That's the kind of guardrail that prevents the classic bug where a cleared search accidentally shows the zero-data empty state.
If you want this to look genuinely polished, consider matching your empty state visual style to the rest of your UI theme. If your app uses Empire UI's neobrutalism components — bold borders, high contrast, flat shadows — your empty state illustrations and button styles should follow that same language. Consistency across states is what separates a professional product from a patchwork one.
From here, the natural next step is exploring Empire UI's component library — a lot of the groundwork (consistent dark mode tokens, rounded corner scales, responsive grid systems) is already done for you. Don't rebuild what's already sitting there free and ready to copy.
FAQ
A skeleton loader signals that content is loading — use it only when you expect data to arrive. An empty state signals that no content exists. Show the skeleton while fetching, then switch to the appropriate empty state once you know the result.
Icons work fine for utility UIs and dense dashboards. Illustrations shine in marketing-adjacent products where brand personality matters. Either way, the visual should match the specific context — avoid generic placeholder art.
Use a discriminated union type for your data state: loading, empty, no-results, and data. TypeScript will enforce that you handle every case, which eliminates the common bug where the wrong empty state renders after a cleared search.
Yes — animate-pulse uses CSS opacity animation, not repaints, so it stays smooth even on mid-range hardware. It cycles at 2 seconds by default; override the keyframe in tailwind.config.js if you want a faster pulse.