ComponentsFeedback

Empty state

What a list or page shows when there is nothing in it: why it is empty and the one next step. First run, no match, not connected yet, or failed to load.

Playground

Change the controls to try each prop. The code under the preview updates as you go and lists only the props you changed; copy it into your page. The examples below show the full layout with its parts.

No notes on Halcyon Bank yet.
import { Empty } from '@/ui/empty';<Empty>  No notes on Halcyon Bank yet.</Empty>

First run

Nothing exists yet. A 16px icon on the 32px tile, the title, one line, then one primary and an optional ghost action.

No companies yet

Drop a spreadsheet anywhere on this page, or add your first client by hand.

import { BuildingsIcon } from '@phosphor-icons/react';
import { Button } from '@/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/ui/empty';

export default function EmptyDefault() {
  return (
    <Empty>
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <BuildingsIcon />
        </EmptyMedia>
        <EmptyTitle>No companies yet</EmptyTitle>
        <EmptyDescription>Drop a spreadsheet anywhere on this page, or add your first client by hand.</EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button variant="primary">Import</Button>
        <Button variant="ghost">Add by hand</Button>
      </EmptyContent>
    </Empty>
  );
}

No matches

Records exist but the search or filters hide them. No tile: say how many are hidden and offer the way back.

No candidates match this view

Try a different search, or clear the filters to see every candidate.

12 hidden by filters
import { Button } from '@/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@/ui/empty';

export default function EmptyNoMatch() {
  return (
    <Empty>
      <EmptyHeader>
        <EmptyTitle>No candidates match this view</EmptyTitle>
        <EmptyDescription>Try a different search, or clear the filters to see every candidate.</EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        12 hidden by filters
        <Button>Clear search and filters</Button>
      </EmptyContent>
    </Empty>
  );
}

Not connected yet

The page needs a connection first. The button names what it connects.

Connect your mailbox

Inbox shows mail with your clients and candidates once your mailbox is connected.

import { EnvelopeSimpleIcon } from '@phosphor-icons/react';
import { Button } from '@/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/ui/empty';

export default function EmptyNotConnected() {
  return (
    <Empty>
      <EmptyHeader>
        <EmptyMedia variant="icon">
          <EnvelopeSimpleIcon />
        </EmptyMedia>
        <EmptyTitle>Connect your mailbox</EmptyTitle>
        <EmptyDescription>Inbox shows mail with your clients and candidates once your mailbox is connected.</EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button variant="primary">Connect mailbox</Button>
      </EmptyContent>
    </Empty>
  );
}

Inside a section

size="inline" is one muted line, with no tile and no button of its own. The section's own Add action does the work.

Notes

No notes on Halcyon Bank yet.
import { PlusIcon } from '@phosphor-icons/react';
import { Button } from '@/ui/button';
import { Empty } from '@/ui/empty';

export default function EmptyInline() {
  return (
    <section aria-labelledby="notes-heading" className="flex w-full max-w-[628px] flex-col gap-100 rounded-xlarge border border-border bg-surface px-[20px] py-200">
      <div className="flex items-center justify-between">
        <h3 id="notes-heading" className="text-heading-small leading-body font-semibold tracking-heading-small text-text">
          Notes
        </h3>
        <Button variant="ghost">
          <PlusIcon />
          Add note
        </Button>
      </div>
      <Empty size="inline">No notes on Halcyon Bank yet.</Empty>
    </section>
  );
}

Failed to load

The same layout with the danger tile and a secondary Try again. Never blame the person.

This page didn't load

Check your connection, then try again.

import { WarningCircleIcon } from '@phosphor-icons/react';
import { Button } from '@/ui/button';
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/ui/empty';

export default function EmptyError() {
  return (
    <Empty>
      <EmptyHeader>
        <EmptyMedia variant="icon" tone="danger">
          <WarningCircleIcon />
        </EmptyMedia>
        <EmptyTitle>This page didn&apos;t load</EmptyTitle>
        <EmptyDescription>Check your connection, then try again.</EmptyDescription>
      </EmptyHeader>
      <EmptyContent>
        <Button>Try again</Button>
      </EmptyContent>
    </Empty>
  );
}

Heading level

The title is an h2. Pass render={<h3 />} when the page's outline needs another level. Links in the description are brand green and underlined.

No meetings this week

Meetings show here once your calendar is connected. Connect your calendar

import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/ui/empty';

export default function EmptyHeadingLevel() {
  return (
    <Empty>
      <EmptyHeader>
        <EmptyTitle render={<h3 />}>No meetings this week</EmptyTitle>
        <EmptyDescription>
          Meetings show here once your calendar is connected. <a href="#connect-calendar">Connect your calendar</a>
        </EmptyDescription>
      </EmptyHeader>
    </Empty>
  );
}

On this page