# Empty State

A placeholder for an area that has no content to show, and a box for a load that failed.

```tsx
import { Button, EmptyState, toast } from 'ferry-ui'
import { Plus, Receipt } from 'lucide-react'

export default function EmptyStateHero() {
  return (
    <EmptyState
      icon={<Receipt />}
      title="No invoices yet"
      description="The invoices that you send to your customers show here."
      actions={
        <Button variant="primary" icon={<Plus />} onClick={() => toast.success('Invoice created')}>
          New invoice
        </Button>
      }
    />
  )
}
```

## Usage guidelines

- **No content is a state.** Use `EmptyState` for an empty list, a first run and a search with no result. Say what goes there. Give the button that adds it.
- **A first load with an error.** If the first load fails and there is no data to show, use `ErrorState` with `onRetry`.
- **Old data stays.** If a refresh fails, keep the data on the page. Put `StaleDataCallout` above it. See [Callout](/docs/components/callout).
- **A table has its own rows.** In a table body, use the rows of [Table States](/docs/components/table-states).
- **A load is not empty.** While the data loads, show a [Skeleton](/docs/components/skeleton).

## Anatomy

Import the components. Each component has one part.

```tsx title="Anatomy"

<EmptyState icon={<Icon />} title="" description="" actions={<Button />} />

<ErrorState title="" error={error} onRetry={retry} />
```

## Examples

### Variants

The `variant` prop sets the frame.

```tsx
import { EmptyState } from 'ferry-ui'
import { FileQuestion, FolderKanban, MessageSquare } from 'lucide-react'

export default function EmptyStateVariants() {
  return (
    <div className="grid gap-4 sm:grid-cols-3">
      <EmptyState
        variant="dashed"
        size="sm"
        icon={<FolderKanban />}
        title="No projects yet"
        description="The dashed variant."
      />
      <EmptyState
        variant="bordered"
        size="sm"
        icon={<FileQuestion />}
        title="Page not found"
        description="The bordered variant."
      />
      <EmptyState
        variant="plain"
        size="sm"
        icon={<MessageSquare />}
        title="No comments yet"
        description="The plain variant."
      />
    </div>
  )
}
```

| Variant | Frame | Use |
| --- | --- | --- |
| `dashed` (default) | A dashed border. | An empty list, a drop zone. |
| `bordered` | A card with a shadow. | A full page: not found, crashed, no access. |
| `plain` | No frame. | In a card, a panel or a popover. |

### Sizes

The `size` prop sets the vertical padding.

```tsx
import { EmptyState } from 'ferry-ui'
import { Users } from 'lucide-react'

export default function EmptyStateSizes() {
  return (
    <div className="grid items-start gap-4 sm:grid-cols-3">
      <EmptyState size="sm" icon={<Users />} title="No members yet" description="Small, 24px of padding." />
      <EmptyState size="md" icon={<Users />} title="No members yet" description="Medium, 40px of padding." />
      <EmptyState size="lg" icon={<Users />} title="No members yet" description="Large, 64px of padding." />
    </div>
  )
}
```

| Size | Padding | Use |
| --- | --- | --- |
| `sm` | 24px | Cards and side panels. |
| `md` (default) | 40px | Sections of a page. |
| `lg` | 64px | A full page, a first run. |

### No search result

If a search or a filter has no result, say what the search was. Give a button that clears it.

```tsx
import * as React from 'react'
import { Button, EmptyState, SearchInput } from 'ferry-ui'
import { SearchX } from 'lucide-react'

const CUSTOMERS = ['Acme', 'Globex', 'Northwind Traders']

export default function EmptyStateNoResults() {
  const [query, setQuery] = React.useState('payroll')
  const matches = CUSTOMERS.filter((name) => name.toLowerCase().includes(query.trim().toLowerCase()))

  return (
    <div className="mx-auto flex w-full max-w-md flex-col gap-4">
      <SearchInput placeholder="Search customers" value={query} onValueChange={setQuery} className="w-full" />
      {matches.length === 0 ? (
        <EmptyState
          icon={<SearchX />}
          title={`No results for "${query}"`}
          description="Check the spelling or search for another name."
          actions={<Button onClick={() => setQuery('')}>Clear search</Button>}
        />
      ) : (
        <ul className="divide-y rounded-lg border bg-surface-100 text-sm text-foreground">
          {matches.map((name) => (
            <li key={name} className="px-4 py-2.5">
              {name}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}
```

### In a card

The card is the frame. Use the `plain` variant and the `sm` size.

```tsx
import { Card, CardContent, CardHeader, CardTitle, EmptyState } from 'ferry-ui'
import { History } from 'lucide-react'

export default function EmptyStateInCard() {
  return (
    <Card className="w-full max-w-sm">
      <CardHeader>
        <CardTitle>Recent activity</CardTitle>
      </CardHeader>
      <CardContent>
        <EmptyState
          variant="plain"
          size="sm"
          icon={<History />}
          title="No activity yet"
          description="Edits, comments and invitations show here."
        />
      </CardContent>
    </Card>
  )
}
```

### Full page

For a page that has no content at all, use the `bordered` variant and the `lg` size. Give a button that opens another page.

```tsx
import { Button, EmptyState, toast } from 'ferry-ui'
import { FileQuestion } from 'lucide-react'

export default function EmptyStateFullPage() {
  return (
    <EmptyState
      variant="bordered"
      size="lg"
      icon={<FileQuestion />}
      title="Page not found"
      description="This page has a new address, or it does not exist."
      actions={
        <>
          <Button onClick={() => toast.info('Back to the last page')}>Go back</Button>
          <Button variant="primary" onClick={() => toast.info('Dashboard opened')}>
            Go to the dashboard
          </Button>
        </>
      }
    />
  )
}
```

### More content

The children show below the actions. Use them for a code sample or a help link.

```tsx
import { Button, CodeBlock, EmptyState, toast } from 'ferry-ui'
import { KeyRound, Plus } from 'lucide-react'

export default function EmptyStateChildren() {
  return (
    <EmptyState
      icon={<KeyRound />}
      title="No API keys yet"
      description="Your server uses an API key to call the API."
      actions={
        <Button variant="primary" icon={<Plus />} onClick={() => toast.success('API key created')}>
          New API key
        </Button>
      }
    >
      <div className="mt-2 flex w-full max-w-sm flex-col gap-2 text-left">
        <p className="text-[13px] text-foreground-lighter">Or use the command line:</p>
        <CodeBlock prompt code="acme keys create" what="command" />
      </div>
    </EmptyState>
  )
}
```

### Load error

`ErrorState` shows a title and the message of `error`. With `onRetry`, it also shows a Retry button. Set `retrying` while the new load is in progress.

```tsx
import * as React from 'react'
import { ErrorState } from 'ferry-ui'

export default function ErrorStateRetry() {
  const [retrying, setRetrying] = React.useState(false)

  function retry() {
    setRetrying(true)
    window.setTimeout(() => setRetrying(false), 1500)
  }

  return (
    <ErrorState
      title="Could not load invoices"
      error={new Error('The request timed out after 30 seconds.')}
      onRetry={retry}
      retrying={retrying}
    />
  )
}
```

### Error with no retry

If a retry cannot help, do not pass `onRetry`. The button does not show. Use `description` to replace the message of `error`.

```tsx
import { ErrorState } from 'ferry-ui'

export default function ErrorStateDescription() {
  return (
    <ErrorState
      title="You do not have access to billing"
      error={new Error('403 billing_admin_required')}
      description="Only a billing admin can see the invoices. Ask the owner of the workspace for this role."
    />
  )
}
```

## Accessibility

- `ErrorState` has the `alert` role. Screen readers read it when it shows.
- The icon of `EmptyState` is decorative. Write the meaning in the title.

## API reference

The two components also accept each attribute of the `<div>` element.

### EmptyState

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title` (required) | `ReactNode` |  | One short sentence saying what is missing ("No invoices yet", "No results for “acme”"). |
| `icon` | `ReactNode` |  | Lucide icon (or any 18px glyph) above the title, drawn in the lighter foreground. Tint it with a class (`text-destructive`) for error-like states. |
| `description` | `ReactNode` |  | What the area is for and how to fill it, in one or two sentences. |
| `actions` | `ReactNode` |  | Call-to-action buttons, centered under the text. Usually one `primary` button (create / import) or a "Clear filters" button. |
| `variant` | `"dashed" \| "bordered" \| "plain"` | `dashed` | Frame of the block: - `dashed` (default) — dashed border; "there is room for content here" (empty lists, drop zones). - `bordered` — solid card with a shadow; standalone pages (not found, crashed, no access). - `plain` — no frame; inside an existing card, panel or popover. |
| `size` | `"sm" \| "md" \| "lg"` | `md` | Vertical padding: `sm` 24px (cards, side panels), `md` 40px (default, sections), `lg` 64px (whole page / first-run). |
| `children` | `ReactNode` |  | Extra content under the actions: a code snippet, a help link, a small illustration. |

### ErrorState

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `error` | `unknown` |  | What was thrown or rejected (an `Error`, a string, anything). Its message is shown under the title through `getErrorMessage`; a value without a message shows the title alone. Ignored when `description` is set. |
| `title` | `ReactNode` | `Something went wrong` | Headline naming what failed ("Could not load invoices"). Defaults to "Something went wrong". |
| `description` | `ReactNode` |  | Custom message replacing the one read from `error` (e.g. a friendlier text for a known error code). |
| `onRetry` | `(() => void)` |  | Shows a Retry button that calls it. Omit it when retrying cannot help (e.g. a permission error). |
| `retrying` | `boolean` |  | Spinner on the Retry button while the new attempt runs. |
| `retryLabel` | `ReactNode` | `Retry` | Label of the Retry button. Defaults to "Retry". |
