Skip to content

Emptied by the user

The inbox-zero case, which should read as an achievement.

Statesstarteremptyinbox-zeroarchiveachievement

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 { Archive, Undo2 } from 'lucide-react'
import { EmptyState } from '@/components/ui/empty-state'
import { Button } from '@/components/ui/button'
import { Panel } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'

/**
 * Emptied-by-the-user state
 *
 * The inbox-zero case. It should read as an achievement rather than an absence,
 * and — critically — it should still offer a way back to the archived items,
 * because "where did everything go" is the next question.
 */
export default function ArchivedEmptyState() {
  return (
    <section className="bg-canvas p-4 sm:p-6">
      <Panel
        title="Inbox"
        description="Nothing needs your attention"
        headingLevel="h2"
        action={<Badge tone="success">Clear</Badge>}
      >
        <EmptyState
          appearance="bare"
          icon={<Archive className="size-5" />}
          title="You are all caught up"
          description="Every notification has been read or archived. New ones will appear here."
          action={
            <Button size="sm" variant="outline" leadingIcon={<Undo2 className="size-3.5" />}>
              View 128 archived
            </Button>
          }
        />
      </Panel>
    </section>
  )
}

components/blocks/sections/empty/archived.tsx

import { Archive, Undo2 } from 'lucide-react'
import { EmptyState } from '@/components/ui/empty-state'
import { Button } from '@/components/ui/button'
import { Panel } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'

/**
 * Emptied-by-the-user state
 *
 * The inbox-zero case. It should read as an achievement rather than an absence,
 * and — critically — it should still offer a way back to the archived items,
 * because "where did everything go" is the next question.
 */
export default function ArchivedEmptyState() {
  return (
    <section className="bg-canvas p-4 sm:p-6">
      <Panel
        title="Inbox"
        description="Nothing needs your attention"
        headingLevel="h2"
        action={<Badge tone="success">Clear</Badge>}
      >
        <EmptyState
          appearance="bare"
          icon={<Archive className="size-5" />}
          title="You are all caught up"
          description="Every notification has been read or archived. New ones will appear here."
          action={
            <Button size="sm" variant="outline" leadingIcon={<Undo2 className="size-3.5" />}>
              View 128 archived
            </Button>
          }
        />
      </Panel>
    </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 still offers a way back to the archived items, because 'where did everything go' is the next question.

  • Say what will appear here next, so the state does not read as permanent.
  • Always offer the route back to what was archived.

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.

  • Bare empty state
  • Archive count
  • Panel enclosure

Accessibility

Bare appearance
No box-in-a-box inside the existing panel.
Counted action
The archive action states how many items it holds.

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.