Skip to content

First-run empty state

The state a new account lands in, with one action and two learning paths.

StatesstarterFeaturedemptyfirst-runonboardingzero-state

Live preview

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

Source

This exact file renders the preview above.

import { ArrowRight, BookOpen, GitBranch, Rocket } from 'lucide-react'
import Link from 'next/link'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'

/**
 * First-run empty state
 *
 * The state a new account lands in. It does two jobs at once: explain what the
 * screen will contain, and offer the single action that fills it. The
 * secondary links are learning paths, not alternative actions — offering four
 * equal buttons to someone with nothing yet is paralysing.
 */
const learn = [
  { icon: BookOpen, label: 'Read the getting-started guide', href: '/docs/getting-started' },
  { icon: GitBranch, label: 'See how deployments work', href: '/docs/composition' },
]

export default function FirstRunEmptyState() {
  return (
    <section className="bg-canvas py-section">
      <Container size="narrow">
        <EmptyState
          size="lg"
          icon={<Rocket className="size-5" />}
          title="No deployments yet"
          description="Connect a repository and Foundry will build every push to your default branch. Your deployment history will appear here."
          action={<ButtonLink href="/forms/setup-wizard">Connect a repository</ButtonLink>}
        />

        <ul className="mx-auto mt-8 flex max-w-sm flex-col gap-2">
          {learn.map((item) => {
            const Icon = item.icon
            return (
              <li key={item.href}>
                <Link
                  href={item.href}
                  className="group flex items-center gap-3 rounded-lg border border-line bg-surface px-4 py-3 transition-colors hover:border-accent"
                >
                  <Icon className="size-4 shrink-0 text-ink-subtle" aria-hidden="true" />
                  <span className="flex-1 text-sm text-ink group-hover:text-accent">
                    {item.label}
                  </span>
                  <ArrowRight
                    className="size-4 shrink-0 text-ink-subtle transition-transform group-hover:translate-x-0.5"
                    aria-hidden="true"
                  />
                </Link>
              </li>
            )
          })}
        </ul>
      </Container>
    </section>
  )
}

components/blocks/sections/empty/first-run.tsx

import { ArrowRight, BookOpen, GitBranch, Rocket } from 'lucide-react'
import Link from 'next/link'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'

/**
 * First-run empty state
 *
 * The state a new account lands in. It does two jobs at once: explain what the
 * screen will contain, and offer the single action that fills it. The
 * secondary links are learning paths, not alternative actions — offering four
 * equal buttons to someone with nothing yet is paralysing.
 */
const learn = [
  { icon: BookOpen, label: 'Read the getting-started guide', href: '/docs/getting-started' },
  { icon: GitBranch, label: 'See how deployments work', href: '/docs/composition' },
]

export default function FirstRunEmptyState() {
  return (
    <section className="bg-canvas py-section">
      <Container size="narrow">
        <EmptyState
          size="lg"
          icon={<Rocket className="size-5" />}
          title="No deployments yet"
          description="Connect a repository and Foundry will build every push to your default branch. Your deployment history will appear here."
          action={<ButtonLink href="/forms/setup-wizard">Connect a repository</ButtonLink>}
        />

        <ul className="mx-auto mt-8 flex max-w-sm flex-col gap-2">
          {learn.map((item) => {
            const Icon = item.icon
            return (
              <li key={item.href}>
                <Link
                  href={item.href}
                  className="group flex items-center gap-3 rounded-lg border border-line bg-surface px-4 py-3 transition-colors hover:border-accent"
                >
                  <Icon className="size-4 shrink-0 text-ink-subtle" aria-hidden="true" />
                  <span className="flex-1 text-sm text-ink group-hover:text-accent">
                    {item.label}
                  </span>
                  <ArrowRight
                    className="size-4 shrink-0 text-ink-subtle transition-transform group-hover:translate-x-0.5"
                    aria-hidden="true"
                  />
                </Link>
              </li>
            )
          })}
        </ul>
      </Container>
    </section>
  )
}

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

It does two jobs: explain what the screen will contain, and offer the single action that fills it. Four equal buttons for someone with nothing yet is paralysing.

  • One action. The learning links are paths, not alternatives.
  • Describe what will appear here, not just that it is empty.

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.

  • Single primary action
  • Learning links
  • Panel enclosure

Accessibility

Heading semantics
The title is a paragraph, so it does not disrupt the page outline.
Named links
Each learning link describes its destination.

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.