---
name: aircall-blocks/migrate-dashboard/empty-states
description: >
  Migrate @dashboard/library MessageScreen family (ComingSoonScreen,
  RestrictedAccessScreen, NotFoundScreen, NoDataScreen, NoSupportScreen,
  UnknownErrorScreen) to @aircall/blocks composable empty-state blocks.
  Load when a file imports any of those from @dashboard/library.
type: sub-skill
library: aircall-blocks
requires:
  - aircall-blocks/setup
  - aircall-blocks/migrate-dashboard
sources:
  - "aircall/hydra:docs/migration-guides/tractor-to-ds/recipes/empty-states.md"
---

This skill builds on aircall-blocks/migrate-dashboard.

## 1. Variant mapping

| `@dashboard/library` | `@aircall/blocks` root | Old asset | New icon |
| --- | --- | --- | --- |
| `ComingSoonScreen` | `ComingSoonEmptyState` | `aircall-placeholder.svg` | `Sprout` |
| `RestrictedAccessScreen` | `RestrictedAccessEmptyState` | `lock.svg` | `LockKeyhole` |
| `NotFoundScreen` | `NotFoundEmptyState` | `not-found.svg` | `Search` |
| `NoDataScreen` | `NoDataEmptyState` | `no-data.svg` | `Search` |
| `NoSupportScreen` | `NoSupportEmptyState` | `settings.svg` | `Settings` |
| `UnknownErrorScreen` | `UnknownErrorEmptyState` | `warning.svg` | `TriangleAlert` |

Each preset exports four individually importable parts — `*EmptyState` (root), `*Media`, `*Title`, `*Description` — all from `@aircall/blocks`.

## 2. Prop mapping

| `MessageScreen` prop | New equivalent |
| --- | --- |
| `title` | children of `*Title` — omit to use the baked-in design copy |
| `subtitle` (string or element) | children of `*Description` |
| `buttonText` + `onClick` | `EmptyStateButton` inside `EmptyStateActions` |
| `image` | fixed per variant — drop it |
| `data-test` | pass to the root: `<NotFoundEmptyState data-test="…">` |
| `w` / `h` / `backgroundColor` | drop — constrain the parent instead |

## 3. Before / After examples

### 3a. NotFoundScreen → NotFoundEmptyState

**Before (`@dashboard/library`):**
```tsx
import { NotFoundScreen } from '@dashboard/library';

<NotFoundScreen
  title="We couldn't find that page"
  subtitle="It may have been moved, deleted, or the link may be incorrect."
  buttonText="Go to inbox"
  onClick={goToInbox}
/>
```

**After (`@aircall/blocks`):**
```tsx
import {
  NotFoundEmptyState,
  NotFoundMedia,
  NotFoundTitle,
  NotFoundDescription,
  EmptyStateHeader,
  EmptyStateActions,
  EmptyStateButton
} from '@aircall/blocks';

<NotFoundEmptyState>
  <EmptyStateHeader>
    <NotFoundMedia />
    <NotFoundTitle />
    <NotFoundDescription />
  </EmptyStateHeader>
  <EmptyStateActions>
    <EmptyStateButton variant="outline" onClick={goToInbox}>
      Go to inbox
    </EmptyStateButton>
  </EmptyStateActions>
</NotFoundEmptyState>
```

The `*Title` and `*Description` ship the Figma copy as defaults — drop `title`/`subtitle` props to adopt them, or override by passing children.

### 3b. ComingSoonScreen → ComingSoonEmptyState (two buttons)

**Before (`@dashboard/library`):**
```tsx
import { ComingSoonScreen } from '@dashboard/library';

<ComingSoonScreen
  title="Coming soon!"
  subtitle="You'll be able to access this feature in the near future."
  buttonText="Go back"
  onClick={goBack}
/>
```

**After (`@aircall/blocks`):**
```tsx
import {
  ComingSoonEmptyState,
  ComingSoonMedia,
  ComingSoonTitle,
  ComingSoonDescription,
  EmptyStateHeader,
  EmptyStateActions,
  EmptyStateButton,
  EmptyStateExternalLink
} from '@aircall/blocks';

<ComingSoonEmptyState>
  <EmptyStateHeader>
    <ComingSoonMedia />
    <ComingSoonTitle />
    <ComingSoonDescription />
  </EmptyStateHeader>
  <EmptyStateActions>
    <EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
  </EmptyStateActions>
  <EmptyStateExternalLink href={docsUrl} />
</ComingSoonEmptyState>
```

`EmptyStateExternalLink` is a sibling of `EmptyStateActions` (not inside it) so it sits on its own line below the buttons.

### 3c. UnknownErrorScreen → UnknownErrorEmptyState (error state)

**Before (`@dashboard/library`):**
```tsx
import { UnknownErrorScreen } from '@dashboard/library';

<UnknownErrorScreen
  title="Something went wrong"
  subtitle="Please try again."
  buttonText="Try again"
  onClick={reload}
/>
```

**After (`@aircall/blocks`):**
```tsx
import {
  UnknownErrorEmptyState,
  UnknownErrorMedia,
  UnknownErrorTitle,
  UnknownErrorDescription,
  EmptyStateHeader,
  EmptyStateActions,
  EmptyStateButton
} from '@aircall/blocks';

<UnknownErrorEmptyState>
  <EmptyStateHeader>
    <UnknownErrorMedia />
    <UnknownErrorTitle />
    <UnknownErrorDescription />
  </EmptyStateHeader>
  <EmptyStateActions>
    <EmptyStateButton variant="default" onClick={reload}>Try again</EmptyStateButton>
  </EmptyStateActions>
</UnknownErrorEmptyState>
```

`UnknownErrorEmptyState` carries `role="alert"` — it announces assertively to screen readers when it mounts. `NoDataEmptyState` carries `role="status"` (polite). The others are static page content with no live region.

### 3d. RestrictedAccessScreen → RestrictedAccessEmptyState (learn more link)

**Before (`@dashboard/library`):**
```tsx
import { RestrictedAccessScreen } from '@dashboard/library';

<RestrictedAccessScreen
  title="You don't have access to this page."
  subtitle="Contact an admin on your team to get access."
  buttonText="Learn more"
  onClick={() => window.open(docsUrl)}
/>
```

**After (`@aircall/blocks`):**
```tsx
import {
  RestrictedAccessEmptyState,
  RestrictedAccessMedia,
  RestrictedAccessTitle,
  RestrictedAccessDescription,
  EmptyStateHeader,
  EmptyStateExternalLink
} from '@aircall/blocks';

<RestrictedAccessEmptyState>
  <EmptyStateHeader>
    <RestrictedAccessMedia />
    <RestrictedAccessTitle />
    <RestrictedAccessDescription />
  </EmptyStateHeader>
  <EmptyStateExternalLink href={docsUrl} />
</RestrictedAccessEmptyState>
```

Use `EmptyStateExternalLink` (renders a real `<a target="_blank">`) instead of an `onClick` that calls `window.open`.

### 3e. Custom / one-off empty state (no variant)

When migrating a bespoke `MessageScreen` usage (custom `image` prop), use the generic family directly:

```tsx
import {
  EmptyState,
  EmptyStateHeader,
  EmptyStateMedia,
  EmptyStateTitle,
  EmptyStateDescription,
  EmptyStateActions,
  EmptyStateButton
} from '@aircall/blocks';
import { Sparkles } from '@aircall/react-icons';

<EmptyState>
  <EmptyStateHeader>
    <EmptyStateMedia>
      <Sparkles />
    </EmptyStateMedia>
    <EmptyStateTitle>No integrations yet</EmptyStateTitle>
    <EmptyStateDescription>Connect your first integration to get started.</EmptyStateDescription>
  </EmptyStateHeader>
  <EmptyStateActions>
    <EmptyStateButton variant="default" onClick={openCatalog}>Browse catalog</EmptyStateButton>
  </EmptyStateActions>
</EmptyState>
```

Icons must come from `@aircall/react-icons`, never from `lucide-react` directly.

## 4. Common Mistakes

### Mistake 1: Passing `title`/`subtitle` as props to the root

```tsx
// WRONG — the root block has no title/subtitle props; they are silently ignored
<NotFoundEmptyState title="Page not found" subtitle="Check the URL." />

// CORRECT — pass custom copy as children of the *Title / *Description parts
<NotFoundEmptyState>
  <EmptyStateHeader>
    <NotFoundMedia />
    <NotFoundTitle>Page not found</NotFoundTitle>
    <NotFoundDescription>Check the URL.</NotFoundDescription>
  </EmptyStateHeader>
</NotFoundEmptyState>
```

Mechanism: the old `MessageScreen` was a single component that accepted flat string props. The new blocks are a composable family; the root only accepts layout-level props (`data-test`, `role`, `className`, …). Title and description are separate sub-components.

Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyState.Props` extends `React.ComponentProps<typeof Empty>`, which has no `title` or `subtitle`.

---

### Mistake 2: Placing `EmptyStateExternalLink` inside `EmptyStateActions`

```tsx
// WRONG — the link ends up in the same flex row as the buttons
<EmptyStateActions>
  <EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
  <EmptyStateExternalLink href={docsUrl} />
</EmptyStateActions>

// CORRECT — place it as a sibling after EmptyStateActions
<EmptyStateActions>
  <EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
</EmptyStateActions>
<EmptyStateExternalLink href={docsUrl} />
```

Mechanism: `EmptyStateActions` is a flex row (`flex-row flex-wrap justify-center gap-3`). Placing the link inside it makes it appear inline with the buttons at the same level, breaking the stacked design where the link sits on its own line below.

Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateActions` renders `EmptyContent` with `flex-row` class; `EmptyStateExternalLink` is meant to be a sibling in the `EmptyState` root's column flow.

---

### Mistake 3: Setting `size` on `EmptyStateButton`

```tsx
// WRONG — size is locked; passing it is a type error and overrides the design spec
<EmptyStateButton variant="outline" size="sm" onClick={goBack}>Go back</EmptyStateButton>

// CORRECT — omit size; only variant is yours to choose
<EmptyStateButton variant="outline" onClick={goBack}>Go back</EmptyStateButton>
```

Mechanism: `EmptyStateButtonProps` is `Omit<React.ComponentProps<typeof Button>, 'size'>` — `size` is explicitly removed from the type. The button always renders at `size="default"` to keep all empty states visually consistent regardless of the surrounding UI.

Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateButton` wraps `Button` with `size="default"` hardcoded; the `size` key is stripped from the exported props type.

---

### Mistake 4: Carrying over `w`, `h`, or `backgroundColor` from `Gap` props

```tsx
// WRONG — these props do not exist on the new blocks root
<NotFoundEmptyState w="100%" h="100%" backgroundColor="neutral-100" />

// CORRECT — the block fills its container and centers automatically; constrain the parent
<div className="h-full w-full">
  <NotFoundEmptyState />
</div>
```

Mechanism: the old `MessageScreen` extended `GapProps` (Tractor) which exposed `w`/`h`/`backgroundColor` to fill the viewport. The new `*EmptyState` roots extend `React.ComponentProps<typeof Empty>` (DS), which is a plain `<div>` with Tailwind layout — these props do not exist.

Source: `packages/blocks/src/components/empty-state.tsx` — `EmptyStateProps` extends `React.ComponentProps<typeof Empty>`; `packages/blocks/src/components/coming-soon.tsx` — `ComingSoonEmptyState` is `ComingSoon.Root` with the same props shape.

---

### Mistake 5: Importing icons from `lucide-react` directly in generic empty states

```tsx
// WRONG — imports lucide-react directly, bypassing Aircall's icon layer
import { Sparkles } from 'lucide-react';
import { EmptyState, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle } from '@aircall/blocks';

<EmptyState>
  <EmptyStateHeader>
    <EmptyStateMedia><Sparkles /></EmptyStateMedia>
    <EmptyStateTitle>No integrations yet</EmptyStateTitle>
  </EmptyStateHeader>
</EmptyState>

// CORRECT — always import from @aircall/react-icons
import { Sparkles } from '@aircall/react-icons';
import { EmptyState, EmptyStateHeader, EmptyStateMedia, EmptyStateTitle } from '@aircall/blocks';

<EmptyState>
  <EmptyStateHeader>
    <EmptyStateMedia><Sparkles /></EmptyStateMedia>
    <EmptyStateTitle>No integrations yet</EmptyStateTitle>
  </EmptyStateHeader>
</EmptyState>
```

Mechanism: `@aircall/react-icons` re-exports all lucide icons plus Aircall custom icons; importing from `lucide-react` directly bypasses any Aircall overrides and breaks the single icon source-of-truth. All six built-in presets source their icons through `@aircall/react-icons` — custom media slots should follow the same convention.

Source: `packages/blocks/src/components/coming-soon.tsx` — imports `Sprout` from `@aircall/react-icons`, not from `lucide-react`.
