Skip to content
UI Kit
Accent color

Empty state

First runs, empty searches, errors and finished inboxes, with a next step.

One of 4 components in Progress and states

3 variants

Variants

First run

A drawn stack of cards and two ways to begin, inside an app.

States

Done, error, locked and offline as small cards.

Details

What Empty state brings along, read from the registry and the source.

Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/empty-state.tsx
Brings along
utilskiticonbutton
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The EmptyStateProps interface from components/ui/empty-state.tsx, with the comments from the code.

  • iconoptional
    IconName

    Icon in the middle of the drawn stack, or alone when illustration is false

  • illustrationoptional
    boolean | React.ReactNode

    true draws a stack of cards behind the icon, a node replaces it with your own image

  • titlerequired
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • primaryActionoptional
    EmptyStateAction
  • secondaryActionoptional
    EmptyStateAction
  • variantoptional
    EmptyStateVariant

    plaindashedcard

    plain: no frame. dashed: a drop-zone like frame. card: a raised surface.

  • toneoptional
    EmptyStateTone

    defaultsuccessdestructive

    Color of the icon: success for "all done", destructive for errors

  • sizeoptional
    'sm' | 'md'
  • asoptional
    'h2' | 'h3' | 'h4'

    Heading level for the title

  • childrenoptional
    React.ReactNode

    Extra 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
components/ui/empty-state.tsx
'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.

Often searched

Jump to

Copied to the clipboard