Skip to content

Empty state

The three-part empty state — what is missing, why, and the single most likely next action.

Feedbackstarteremptyzero-stateonboardingno-results

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { FileSearch, Inbox, PlugZap } from 'lucide-react'
import { Button, ButtonLink } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { Panel } from '@/components/ui/card'
import { DemoStage } from './_kit'

export default function EmptyStateDemo() {
  return (
    <DemoStage>
      <EmptyState
        icon={<Inbox className="size-5" />}
        title="No deployments yet"
        description="Connect a repository and Foundry will build every push to your default branch."
        action={<Button size="sm">Connect repository</Button>}
        secondaryAction={
          <ButtonLink href="/docs/getting-started" size="sm" variant="ghost">
            Read the guide
          </ButtonLink>
        }
      />

      <EmptyState
        icon={<FileSearch className="size-5" />}
        title="No results for “observability”"
        description="Check the spelling, or browse the catalogue by category instead."
        action={
          <ButtonLink href="/components" size="sm" variant="outline">
            Browse components
          </ButtonLink>
        }
        size="sm"
      />

      <Panel title="Integrations" description="Bare appearance, for use inside an existing panel.">
        <EmptyState
          appearance="bare"
          size="sm"
          icon={<PlugZap className="size-5" />}
          title="Nothing connected"
          description="Integrations appear here once at least one is authorised."
        />
      </Panel>
    </DemoStage>
  )
}

components/demos/empty-state.tsx

import { FileSearch, Inbox, PlugZap } from 'lucide-react'
import { Button, ButtonLink } from '@/components/ui/button'
import { EmptyState } from '@/components/ui/empty-state'
import { Panel } from '@/components/ui/card'
import { DemoStage } from './_kit'

export default function EmptyStateDemo() {
  return (
    <DemoStage>
      <EmptyState
        icon={<Inbox className="size-5" />}
        title="No deployments yet"
        description="Connect a repository and Foundry will build every push to your default branch."
        action={<Button size="sm">Connect repository</Button>}
        secondaryAction={
          <ButtonLink href="/docs/getting-started" size="sm" variant="ghost">
            Read the guide
          </ButtonLink>
        }
      />

      <EmptyState
        icon={<FileSearch className="size-5" />}
        title="No results for “observability”"
        description="Check the spelling, or browse the catalogue by category instead."
        action={
          <ButtonLink href="/components" size="sm" variant="outline">
            Browse components
          </ButtonLink>
        }
        size="sm"
      />

      <Panel title="Integrations" description="Bare appearance, for use inside an existing panel.">
        <EmptyState
          appearance="bare"
          size="sm"
          icon={<PlugZap className="size-5" />}
          title="Nothing connected"
          description="Integrations appear here once at least one is authorised."
        />
      </Panel>
    </DemoStage>
  )
}

components/ui/empty-state.tsx

import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * EmptyState
 *
 * An empty state is a piece of product writing more than a piece of UI, so the
 * component enforces the three parts that make one useful: what is missing,
 * why it might be missing, and the single most likely next action.
 */
export interface EmptyStateProps {
  icon?: ReactNode
  title: string
  description?: string
  action?: ReactNode
  secondaryAction?: ReactNode
  /** `panel` draws a dashed enclosure; `bare` sits inside an existing panel. */
  appearance?: 'panel' | 'bare'
  size?: 'sm' | 'md' | 'lg'
  className?: string
}

export function EmptyState({
  icon,
  title,
  description,
  action,
  secondaryAction,
  appearance = 'panel',
  size = 'md',
  className,
}: EmptyStateProps) {
  const padding = { sm: 'py-8', md: 'py-12', lg: 'py-20' }[size]

  return (
    <div
      className={cn(
        'flex flex-col items-center px-6 text-center',
        padding,
        appearance === 'panel' &&
          'rounded-lg border border-dashed border-line-strong bg-surface-sunken/60',
        className,
      )}
    >
      {icon ? (
        <div className="mb-4 flex size-11 items-center justify-center rounded-full border border-line bg-surface text-ink-subtle">
          {icon}
        </div>
      ) : null}
      <p className="text-md font-semibold text-ink-strong text-balance">{title}</p>
      {description ? (
        <p className="mt-1.5 max-w-sm text-sm text-ink-muted text-pretty">{description}</p>
      ) : null}
      {(action || secondaryAction) && (
        <div className="mt-5 flex flex-wrap items-center justify-center gap-2">
          {action}
          {secondaryAction}
        </div>
      )}
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

An empty state is product writing more than UI. The component enforces the three parts that make one useful, so a zero-state can never ship as just a grey box saying "No data".

  • Distinguish "nothing yet" from "nothing matched" — they need different copy and different actions.
  • Echo the failed query back to the user so they can see the typo.
  • Bare appearance avoids a box-inside-a-box when the empty state already sits in a panel.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Panel — dashed enclosure
  • Bare — inside an existing panel
  • Three sizes
  • With primary and secondary actions

Accessibility

Heading
The title is a paragraph, not a heading, so it never disrupts the page outline. Add a real heading when the empty state owns a section.
Icon
Decorative and hidden; the title carries the message.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.