Empty state
First runs, empty searches, errors and finished inboxes, with a next step.
One of 4 components in Progress and states
Variants
Details
What Empty state brings along, read from the registry and the source.
- Category
- Progress and states
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/empty-state.tsx
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The EmptyStateProps interface from components/ui/empty-state.tsx, with the comments from the code.
iconoptionalIconNameIcon in the middle of the drawn stack, or alone when illustration is false
illustrationoptionalboolean | React.ReactNodetrue draws a stack of cards behind the icon, a node replaces it with your own image
titlerequiredReact.ReactNodedescriptionoptionalReact.ReactNodeprimaryActionoptionalEmptyStateActionsecondaryActionoptionalEmptyStateActionvariantoptionalEmptyStateVariantplaindashedcard
plain: no frame. dashed: a drop-zone like frame. card: a raised surface.
toneoptionalEmptyStateTonedefaultsuccessdestructive
Color of the icon: success for "all done", destructive for errors
sizeoptional'sm' | 'md'asoptional'h2' | 'h3' | 'h4'Heading level for the title
childrenoptionalReact.ReactNodeExtra content under the actions, e.g. suggestion chips
Accessibility
Read from the source of the component.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 8 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 184 lines
'use client';
import { useRef } from 'react';
import { cn, useInView } from '@/lib/kit';
import { Button, type ButtonVariant } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
export type EmptyStateVariant = 'plain' | 'dashed' | 'card';
export type EmptyStateTone = 'default' | 'success' | 'destructive';
export interface EmptyStateAction {
label: string;
href?: string;
onClick?: () => void;
icon?: IconName;
iconLeft?: IconName;
}
export interface EmptyStateProps {
/** Icon in the middle of the drawn stack, or alone when illustration is false */
icon?: IconName;
/** true draws a stack of cards behind the icon, a node replaces it with your own image */
illustration?: boolean | React.ReactNode;
title: React.ReactNode;
description?: React.ReactNode;
primaryAction?: EmptyStateAction;
secondaryAction?: EmptyStateAction;
/** plain: no frame. dashed: a drop-zone like frame. card: a raised surface. */
variant?: EmptyStateVariant;
/** Color of the icon: success for "all done", destructive for errors */
tone?: EmptyStateTone;
size?: 'sm' | 'md';
/** Heading level for the title */
as?: 'h2' | 'h3' | 'h4';
/** Extra content under the actions, e.g. suggestion chips */
children?: React.ReactNode;
className?: string;
}
const TONE: Record<EmptyStateTone, string> = {
default: 'text-foreground',
success: 'text-success-foreground',
destructive: 'text-[color-mix(in_oklab,var(--destructive)_88%,var(--foreground))]',
};
const TONE_BG: Record<EmptyStateTone, string> = {
default: 'bg-card',
success: 'bg-[color-mix(in_oklab,var(--success)_10%,var(--card))]',
destructive: 'bg-[color-mix(in_oklab,var(--destructive)_9%,var(--card))]',
};
/* The illustration floats quietly, its glow breathes with it. Nothing moves with reduced motion. */
const STYLES =
'.kitfx-empty-float{animation:kitfx-empty-float 5.5s ease-in-out infinite}@keyframes kitfx-empty-float{50%{transform:translateY(-5px)}}' +
'.kitfx-empty-glow{animation:kitfx-empty-glow 5.5s ease-in-out infinite}@keyframes kitfx-empty-glow{50%{opacity:.55;transform:scaleX(.86)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-empty-float,.kitfx-empty-glow{animation:none}}';
const SPRING = 'ease-[cubic-bezier(0.34,1.4,0.64,1)]';
/** Three cards fanned out behind the icon, drawn with the kit tokens. They fan open when the state scrolls in, and a little wider on hover. */
function Stack({ icon, tone, shown, entering }: { icon: IconName; tone: EmptyStateTone; shown: boolean; entering: boolean }) {
return (
<div className="relative mx-auto h-[104px] w-[150px]" aria-hidden>
<div className="kitfx-empty-glow absolute inset-x-6 bottom-0 h-10 rounded-full bg-[var(--glow)] blur-2xl" />
<div
className={cn(
'absolute left-1/2 top-3 h-[76px] w-[96px] rounded-xl border border-border bg-surface-2 shadow-[var(--shadow-sm)] transition-[translate,rotate,opacity] duration-700 motion-reduce:transition-none',
SPRING,
shown ? '-translate-x-[84%] -rotate-[10deg] group-hover/empty:-translate-x-[90%] group-hover/empty:-rotate-[14deg]' : '-translate-x-1/2 rotate-0 opacity-0',
)}
style={{ transitionDelay: entering ? '150ms' : undefined }}
>
<span className="absolute left-3 top-3 h-1.5 w-8 rounded-full bg-border-strong" />
<span className="absolute left-3 top-6 h-1.5 w-12 rounded-full bg-border" />
</div>
<div
className={cn(
'absolute left-1/2 top-3 h-[76px] w-[96px] rounded-xl border border-border bg-surface-2 shadow-[var(--shadow-sm)] transition-[translate,rotate,opacity] duration-700 motion-reduce:transition-none',
SPRING,
shown ? '-translate-x-[16%] rotate-[10deg] group-hover/empty:-translate-x-[10%] group-hover/empty:rotate-[14deg]' : '-translate-x-1/2 rotate-0 opacity-0',
)}
style={{ transitionDelay: entering ? '220ms' : undefined }}
>
<span className="absolute right-3 top-3 h-1.5 w-8 rounded-full bg-border-strong" />
<span className="absolute right-3 top-6 h-1.5 w-10 rounded-full bg-border" />
</div>
<div className={cn('absolute left-1/2 top-0 -translate-x-1/2 transition-[opacity,scale] duration-700 motion-reduce:transition-none', SPRING, !shown && 'scale-90 opacity-0')}>
<div className={cn('kitfx-empty-float grid h-[84px] w-[100px] place-items-center rounded-xl border border-border-strong shadow-[var(--highlight),var(--shadow-md)]', TONE_BG[tone])}>
<span className={cn('grid size-10 place-items-center rounded-lg border border-border bg-background shadow-[var(--shadow-sm)] transition-[scale,rotate] duration-500 group-hover/empty:-rotate-6 group-hover/empty:scale-110 motion-reduce:transition-none', SPRING, TONE[tone])}>
<Icon name={icon} size={20} />
</span>
</div>
</div>
</div>
);
}
function Action({ action, variant, size }: { action: EmptyStateAction; variant: ButtonVariant; size: 'sm' | 'md' }) {
// Small buttons grow to a full 44 px tap target on phones
const common = { variant, size, icon: action.icon, iconLeft: action.iconLeft, className: size === 'sm' ? 'max-sm:h-11 max-sm:min-h-11' : undefined } as const;
return action.href ? (
<Button {...common} href={action.href}>
{action.label}
</Button>
) : (
<Button {...common} onClick={action.onClick}>
{action.label}
</Button>
);
}
/**
* What a page or panel shows when there is nothing yet, nothing found or something went wrong.
* It arrives when it scrolls into view: the drawn stack fans open, then title, text and actions rise.
*/
export function EmptyState({ icon = 'inbox', illustration = true, title, description, primaryAction, secondaryAction, variant = 'plain', tone = 'default', size = 'md', as: Tag = 'h3', children, className }: EmptyStateProps) {
const small = size === 'sm';
const [ref, inView] = useInView<HTMLDivElement>();
const waited = useRef(false);
if (!inView) waited.current = true;
const entering = inView && waited.current;
const rise = (step: number) => ({
className: cn('transition-[opacity,translate] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'translate-y-3 opacity-0'),
style: { transitionDelay: entering ? `${200 + step * 80}ms` : undefined } as React.CSSProperties,
});
const r1 = rise(0);
const r2 = rise(1);
const r3 = rise(2);
const r4 = rise(3);
return (
<div
ref={ref}
className={cn(
'group/empty flex flex-col items-center text-center',
small ? 'px-5 py-8' : 'px-6 py-12 sm:py-16',
variant === 'dashed' && 'rounded-xl border border-dashed border-border-strong bg-surface transition-colors duration-300 hover:border-[color-mix(in_oklab,var(--primary)_45%,var(--border-strong))]',
variant === 'card' && 'kit-card',
className,
)}
>
<style href="kitfx-empty" precedence="default">
{STYLES}
</style>
{illustration === true ? (
small ? (
<span
className={cn('grid size-11 place-items-center rounded-xl border border-border shadow-[var(--highlight),var(--shadow-sm)] transition-[opacity,scale,rotate] duration-700 group-hover/empty:-rotate-6 motion-reduce:transition-none', SPRING, TONE_BG[tone], TONE[tone], !inView && 'scale-50 opacity-0')}
aria-hidden
>
<Icon name={icon} size={20} />
</span>
) : (
<Stack icon={icon} tone={tone} shown={inView} entering={entering} />
)
) : illustration ? (
<div aria-hidden className={cn('transition-[opacity,scale] duration-700 motion-reduce:transition-none', SPRING, !inView && 'scale-90 opacity-0')}>
<div className="kitfx-empty-float">{illustration}</div>
</div>
) : (
<span aria-hidden className={cn('grid transition-[opacity,scale] duration-700 motion-reduce:transition-none', SPRING, !inView && 'scale-50 opacity-0')}>
<Icon name={icon} size={24} className={cn('kitfx-empty-float text-subtle-foreground', tone !== 'default' && TONE[tone])} />
</span>
)}
<Tag className={cn('text-balance font-semibold tracking-[-0.015em] text-foreground', small ? 'mt-4 text-[15px]' : 'mt-7 text-h3', r1.className)} style={r1.style}>
{title}
</Tag>
{description && (
<p className={cn('mt-2 max-w-[26rem] text-pretty text-muted-foreground', small ? 'text-[13px]' : 'text-sm sm:text-[15px]', r2.className)} style={r2.style}>
{description}
</p>
)}
{(primaryAction || secondaryAction) && (
<div className={cn('flex w-full flex-col-reverse justify-center gap-2.5 sm:w-auto sm:flex-row', small ? 'mt-5' : 'mt-7', r3.className)} style={r3.style}>
{secondaryAction && <Action action={secondaryAction} variant="outline" size={small ? 'sm' : 'md'} />}
{primaryAction && <Action action={primaryAction} variant="primary" size={small ? 'sm' : 'md'} />}
</div>
)}
{children && (
<div className={cn('w-full', small ? 'mt-5' : 'mt-8', r4.className)} style={r4.style}>
{children}
</div>
)}
</div>
);
}Frequent questions
How do I install Empty state?
With aiw add @ai-website/empty-state. The command puts components/ui/empty-state.tsx into your project, the code is yours from then on. It brings along icon and button. It needs no further npm packages.
Which variants does Empty state have?
3 variants, First run, No results and States. You pick one with the variant prop, for example variant="card".
Can an AI assistant build with Empty state?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Empty state with aiw_components and install it with aiw_add @ai-website/empty-state. Name the variant in your prompt, for example variant="dashed", and the assistant writes your content straight into the props.