Skip to content

Offline state

Subscribes to real online and offline events rather than pretending.

Statesintermediateerrorofflineconnectivitypwa

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.

'use client'

import { useEffect, useState } from 'react'
import { CloudOff, RefreshCw, Wifi } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'
import { Status } from '@/components/ui/status'

/**
 * Offline state
 *
 * Subscribes to the browser's real online/offline events, so the section
 * reflects actual connectivity rather than pretending. Everything that works
 * offline is listed explicitly — the most reassuring thing an offline screen
 * can do is be specific about what is still available.
 */
const available = [
  'Pages you have already visited',
  'Your theme, palette and density preferences',
  'Anything drafted locally in a form',
]

export default function OfflineSection() {
  const [online, setOnline] = useState(true)

  useEffect(() => {
    const update = () => setOnline(navigator.onLine)
    update()
    window.addEventListener('online', update)
    window.addEventListener('offline', update)
    return () => {
      window.removeEventListener('online', update)
      window.removeEventListener('offline', update)
    }
  }, [])

  return (
    <section className="bg-canvas py-section">
      <Container size="narrow">
        <div className="mb-6 flex justify-center">
          <Status
            appearance="pill"
            kind={online ? 'operational' : 'down'}
            label={online ? 'Connection restored' : 'No connection'}
          />
        </div>

        <EmptyState
          icon={online ? <Wifi className="size-5" /> : <CloudOff className="size-5" />}
          title={online ? 'You are back online' : 'You are offline'}
          description={
            online
              ? 'The connection has returned. Reload to fetch the latest data.'
              : 'Foundry cannot reach the network. Anything you have already loaded is still available.'
          }
          action={
            <Button
              onClick={() => window.location.reload()}
              leadingIcon={<RefreshCw className="size-4" />}
            >
              Reload
            </Button>
          }
        />

        <div className="mx-auto mt-8 max-w-md rounded-lg border border-line bg-surface p-5">
          <p className="label-caps text-ink-subtle">Still available offline</p>
          <ul className="mt-3 flex flex-col gap-2">
            {available.map((item) => (
              <li key={item} className="text-sm text-ink-muted">
                · {item}
              </li>
            ))}
          </ul>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/error/offline.tsx

'use client'

import { useEffect, useState } from 'react'
import { CloudOff, RefreshCw, Wifi } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { EmptyState } from '@/components/ui/empty-state'
import { Status } from '@/components/ui/status'

/**
 * Offline state
 *
 * Subscribes to the browser's real online/offline events, so the section
 * reflects actual connectivity rather than pretending. Everything that works
 * offline is listed explicitly — the most reassuring thing an offline screen
 * can do is be specific about what is still available.
 */
const available = [
  'Pages you have already visited',
  'Your theme, palette and density preferences',
  'Anything drafted locally in a form',
]

export default function OfflineSection() {
  const [online, setOnline] = useState(true)

  useEffect(() => {
    const update = () => setOnline(navigator.onLine)
    update()
    window.addEventListener('online', update)
    window.addEventListener('offline', update)
    return () => {
      window.removeEventListener('online', update)
      window.removeEventListener('offline', update)
    }
  }, [])

  return (
    <section className="bg-canvas py-section">
      <Container size="narrow">
        <div className="mb-6 flex justify-center">
          <Status
            appearance="pill"
            kind={online ? 'operational' : 'down'}
            label={online ? 'Connection restored' : 'No connection'}
          />
        </div>

        <EmptyState
          icon={online ? <Wifi className="size-5" /> : <CloudOff className="size-5" />}
          title={online ? 'You are back online' : 'You are offline'}
          description={
            online
              ? 'The connection has returned. Reload to fetch the latest data.'
              : 'Foundry cannot reach the network. Anything you have already loaded is still available.'
          }
          action={
            <Button
              onClick={() => window.location.reload()}
              leadingIcon={<RefreshCw className="size-4" />}
            >
              Reload
            </Button>
          }
        />

        <div className="mx-auto mt-8 max-w-md rounded-lg border border-line bg-surface p-5">
          <p className="label-caps text-ink-subtle">Still available offline</p>
          <ul className="mt-3 flex flex-col gap-2">
            {available.map((item) => (
              <li key={item} className="text-sm text-ink-muted">
                · {item}
              </li>
            ))}
          </ul>
        </div>
      </Container>
    </section>
  )
}

components/ui/status.tsx

import { CheckCircle2, AlertTriangle, XCircle, Info, CircleDashed, Clock } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Status
 *
 * Deliberately separate from Badge. A status describes the *state of a thing*
 * (a deployment, an invoice, a service) and therefore always pairs an icon
 * with a label — colour is the third signal, never the only one. That rule is
 * what makes the library legible to colour-blind users without a theme switch.
 */
export type StatusKind = 'operational' | 'degraded' | 'down' | 'pending' | 'info' | 'idle'

export interface StatusProps {
  kind: StatusKind
  label: string
  /** `dot` for dense tables, `pill` for standalone display. */
  appearance?: 'dot' | 'pill' | 'inline'
  className?: string
}

const config = {
  operational: {
    icon: CheckCircle2,
    text: 'text-success',
    bg: 'bg-success-soft border-success-line',
    dot: 'bg-success',
  },
  degraded: {
    icon: AlertTriangle,
    text: 'text-warning',
    bg: 'bg-warning-soft border-warning-line',
    dot: 'bg-warning',
  },
  down: {
    icon: XCircle,
    text: 'text-danger',
    bg: 'bg-danger-soft border-danger-line',
    dot: 'bg-danger',
  },
  pending: { icon: Clock, text: 'text-info', bg: 'bg-info-soft border-info-line', dot: 'bg-info' },
  info: { icon: Info, text: 'text-info', bg: 'bg-info-soft border-info-line', dot: 'bg-info' },
  idle: {
    icon: CircleDashed,
    text: 'text-ink-subtle',
    bg: 'bg-surface-sunken border-line',
    dot: 'bg-ink-subtle',
  },
} as const

export function Status({ kind, label, appearance = 'inline', className }: StatusProps) {
  const entry = config[kind]
  const Icon = entry.icon

  if (appearance === 'dot') {
    return (
      <span className={cn('inline-flex items-center gap-2 text-sm text-ink', className)}>
        <span className={cn('size-2 shrink-0 rounded-full', entry.dot)} aria-hidden="true" />
        {label}
      </span>
    )
  }

  if (appearance === 'pill') {
    return (
      <span
        className={cn(
          'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium',
          entry.bg,
          entry.text,
          className,
        )}
      >
        <Icon className="size-3.5 shrink-0" aria-hidden="true" />
        {label}
      </span>
    )
  }

  return (
    <span
      className={cn('inline-flex items-center gap-1.5 text-sm font-medium', entry.text, className)}
    >
      <Icon className="size-4 shrink-0" aria-hidden="true" />
      {label}
    </span>
  )
}

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

The most reassuring thing an offline screen can do is be specific about what is still available, so everything that works offline is listed explicitly.

  • Subscribe to the browser events; a static offline page that lies about the state is worse than none.
  • List what still works, not just what does not.

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.

  • Live connectivity status
  • Reload action
  • What-still-works list

Accessibility

Live status
The status pill updates with real connectivity events.
Recovery
Reload is a real button that works the moment the connection returns.

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.