# Empty state

> What a list says before there is anything in it: an isometric line drawing in your theme that draws itself, changes with the state and acts out the action.

Empty states are usually a grey icon, a sentence and a button, or a stock illustration in someone else's colours that never changes. This one is a line drawing built in code on one isometric tile, drawn with your theme's tokens in light and dark. It draws itself the first time it scrolls into view. When the state changes (nothing yet, no results, can't connect, no data, all done) the old object un-draws and the new one rises from the same tile, while the title morphs letter by letter. The main action is a Status button and the drawing acts it out: the first sheet lands in the tray, the plug slides into its socket, the lens sweeps the empty cards. If the action throws, the piece springs back with a shake and your message folds in under the buttons.

## Install

```bash
npx shadcn@latest add https://hairlineui.com/r/empty-state.json
```

Needs React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

- npm: none
- Hairline UI components it needs (the CLI installs them too): `status-button`, `text-morph`
- Docs and live preview: https://hairlineui.com/ui/empty-state
- Registry item (source): https://hairlineui.com/r/empty-state.json

## Usage

```tsx
import { EmptyState } from "@/components/empty-state";

<EmptyState
  scene="inbox"
  title="No projects yet"
  description="Start one and invite your team."
  action={{ label: "Create project", pendingLabel: "Creating", successLabel: "Created", onAction: createProject }}
/>
```

## In a searchable list

One empty state for every way a list can be empty: the scene follows why it is empty, so a cleared search or a failed load changes the drawing in place.

```tsx
"use client";

import { EmptyState } from "@/components/empty-state";

export function ProjectList({ projects, query, error, onCreate, onRetry, onClear }: Props) {
  if (error) {
    return (
      <EmptyState
        scene="offline"
        title="Couldn't load projects"
        description={error}
        action={{ label: "Retry", pendingLabel: "Loading", successLabel: "Loaded", onAction: onRetry }}
      />
    );
  }
  if (projects.length === 0) {
    return query ? (
      <EmptyState
        scene="search"
        title={`No results for “${query}”`}
        description="Check the spelling, or search by owner or tag."
        secondaryAction={{ label: "Clear search", onAction: onClear }}
      />
    ) : (
      <EmptyState
        scene="inbox"
        title="No projects yet"
        description="Start one and invite your team."
        // Throw to keep the empty state and show why: throw new Error("You've reached 3 projects on Free.")
        action={{ label: "Create project", pendingLabel: "Creating", successLabel: "Created", onAction: onCreate }}
      />
    );
  }
  return <ul>{/* your rows */}</ul>;
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `scene` | `"inbox" \| "search" \| "done" \| "offline" \| "chart"` | `"inbox"` | The drawing: nothing yet, no results, all done, can't connect or no data. Changing it redraws the object on the same tile. |
| `illustration` | `ReactNode` | – | Your own art in place of the drawing. |
| `title` | `string` | – | The headline; changes morph letter by letter. |
| `description` | `ReactNode` | – | A sentence or two under it; changes cross-fade. |
| `action` | `{ label, onAction?, pendingLabel?, successLabel?, errorLabel?, icon? } \| ReactNode` | – | The main action as a Status button whose pending, done and failed states the drawing acts out; onAction may return a promise and throws to fail. Or pass your own button. |
| `secondaryAction` | `{ label, onAction?, href? }` | – | A quieter button or link beside it. |
| `children` | `ReactNode` | – | Anything under the text, such as suggested searches. |
| `className` | `string` | – | Classes for the root. |

## States

- **first view**: The tile draws itself, then the scene: each line traces in 640ms, 34ms after the one behind it, and its face fills over the last third.
- **scene change**: The old object retracts front to back in 340ms while the new one draws in 160ms later; the tile stays.
- **title and description**: The title morphs through TextMorph; the description lifts out through a 4px blur as the new one rises in, and the height eases over 380ms.
- **action running**: The button shows its spinner and pending label; the hero piece eases to its pending pose (the sheet lowers, the plug moves toward the socket).
- **action done**: The check draws in the button and the piece lands in its done pose with a slight settle (620ms).
- **action failed**: The piece returns and shakes ±4px, the button says Try again, and the thrown message folds in under it.

## What it handles

Most empty states are a dead end: a picture that never changes and a button that gives no sign it did anything. This one changes with the state and shows the action working.

- **Art in your theme**: Faces and lines are mixed from your shadcn tokens, so the drawing follows any brand in light and dark; pass illustration to use your own.
- **Five states, one tile**: inbox, search, offline, chart and done share the tile; changing scene un-draws the old object and draws the new one.
- **Drawn when seen**: Lines draw back to front and faces fill once the drawing is on screen, never while it is out of sight.
- **The action, acted out**: onAction runs as Status button (Working → Done); the drawing moves into its pending and done poses with it.
- **Failures said in place**: Throw from onAction: the piece springs back with a shake, the button says Try again and your message folds in.
- **Fits a card or a page**: A container query shrinks the drawing and padding in narrow cards and table bodies.

## Replaces

an empty-state illustration, an SVG icon set, a loading button, an error message, a no-results message

## Where it fits

- **First run**: A new workspace with no projects, and Create project.
- **Search and filters**: No results for a query, with Clear search and suggestions.
- **Integrations and feeds**: Can't reach the API, then connected but no events yet.
- **Inboxes and queues**: Everything handled: you're all caught up.

## Notes

- The drawing is decorative (aria-hidden); the title is a heading and the failure message is announced with role="alert".
- Status button announces Working, Done and Try again outside the button.
- Reduced motion: no drawing, rolling or shaking; scenes cross-fade and text changes in place.
- Below 22rem of container width the drawing shrinks from 168 to 132px wide and the padding tightens.

License: MIT (https://hairlineui.com/license#components)
