Skip to content

Process as a live checklist

Phases with real completion states, including one honestly incomplete.

Contentstarterprocesschecklistroadmapprogress

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 { Container } from '@/components/ui/layout'
import { Progress } from '@/components/ui/progress'
import { Status } from '@/components/ui/status'

/**
 * Process as a live checklist
 *
 * A rollout tracker rather than a sales section: phases with real completion
 * states. Suited to a public roadmap or an internal adoption page, where the
 * honest answer includes what is not done.
 */
const phases = [
  {
    name: 'Foundations',
    progress: 100,
    status: 'operational' as const,
    statusLabel: 'Complete',
    tasks: ['Token set', 'Light and dark schemes', 'Density axis', 'Focus treatment'],
  },
  {
    name: 'Primitives',
    progress: 100,
    status: 'operational' as const,
    statusLabel: 'Complete',
    tasks: ['45 components', 'States and keyboard contracts', 'Usage documentation'],
  },
  {
    name: 'Composition',
    progress: 100,
    status: 'operational' as const,
    statusLabel: 'Complete',
    tasks: ['Sections', 'Page patterns', 'Composition playground'],
  },
  {
    name: 'Verification',
    progress: 62,
    status: 'pending' as const,
    statusLabel: 'In progress',
    tasks: ['Automated route audit', 'Manual screen-reader passes', 'Reduced-motion review'],
  },
]

export default function ChecklistPhasesProcess() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Where the work actually is.
          </h2>
          <p className="mt-3 text-md text-ink-muted">
            Three phases finished, one honestly in progress.
          </p>
        </div>

        <div className="mt-10 grid gap-4 sm:grid-cols-2">
          {phases.map((phase) => (
            <div key={phase.name} className="rounded-xl border border-line bg-surface p-5">
              <div className="flex items-center justify-between gap-3">
                <h3 className="text-md font-semibold text-ink-strong">{phase.name}</h3>
                <Status appearance="pill" kind={phase.status} label={phase.statusLabel} />
              </div>
              <Progress
                value={phase.progress}
                tone={phase.progress === 100 ? 'success' : 'accent'}
                className="mt-4"
                showValue
              />
              <ul className="mt-4 flex flex-col gap-1.5">
                {phase.tasks.map((task) => (
                  <li key={task} className="text-sm text-ink-muted">
                    · {task}
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/process/checklist-phases.tsx

import { Container } from '@/components/ui/layout'
import { Progress } from '@/components/ui/progress'
import { Status } from '@/components/ui/status'

/**
 * Process as a live checklist
 *
 * A rollout tracker rather than a sales section: phases with real completion
 * states. Suited to a public roadmap or an internal adoption page, where the
 * honest answer includes what is not done.
 */
const phases = [
  {
    name: 'Foundations',
    progress: 100,
    status: 'operational' as const,
    statusLabel: 'Complete',
    tasks: ['Token set', 'Light and dark schemes', 'Density axis', 'Focus treatment'],
  },
  {
    name: 'Primitives',
    progress: 100,
    status: 'operational' as const,
    statusLabel: 'Complete',
    tasks: ['45 components', 'States and keyboard contracts', 'Usage documentation'],
  },
  {
    name: 'Composition',
    progress: 100,
    status: 'operational' as const,
    statusLabel: 'Complete',
    tasks: ['Sections', 'Page patterns', 'Composition playground'],
  },
  {
    name: 'Verification',
    progress: 62,
    status: 'pending' as const,
    statusLabel: 'In progress',
    tasks: ['Automated route audit', 'Manual screen-reader passes', 'Reduced-motion review'],
  },
]

export default function ChecklistPhasesProcess() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Where the work actually is.
          </h2>
          <p className="mt-3 text-md text-ink-muted">
            Three phases finished, one honestly in progress.
          </p>
        </div>

        <div className="mt-10 grid gap-4 sm:grid-cols-2">
          {phases.map((phase) => (
            <div key={phase.name} className="rounded-xl border border-line bg-surface p-5">
              <div className="flex items-center justify-between gap-3">
                <h3 className="text-md font-semibold text-ink-strong">{phase.name}</h3>
                <Status appearance="pill" kind={phase.status} label={phase.statusLabel} />
              </div>
              <Progress
                value={phase.progress}
                tone={phase.progress === 100 ? 'success' : 'accent'}
                className="mt-4"
                showValue
              />
              <ul className="mt-4 flex flex-col gap-1.5">
                {phase.tasks.map((task) => (
                  <li key={task} className="text-sm text-ink-muted">
                    · {task}
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </div>
      </Container>
    </section>
  )
}

components/ui/progress.tsx

import { cn } from '@/lib/cn'

/**
 * Progress
 *
 * Determinate by default. When `value` is omitted the bar renders as
 * indeterminate and drops `aria-valuenow`, which is what tells assistive tech
 * "in progress, duration unknown" rather than "0%".
 */
export interface ProgressProps {
  /** 0–100. Omit for an indeterminate bar. */
  value?: number
  label?: string
  /** Renders the numeric value beside the label. */
  showValue?: boolean
  /** Accessible name when no visible label is wanted. */
  srLabel?: string
  tone?: 'accent' | 'success' | 'warning' | 'danger'
  size?: 'sm' | 'md'
  className?: string
}

const tones = {
  accent: 'bg-accent',
  success: 'bg-success',
  warning: 'bg-warning',
  danger: 'bg-danger',
} as const

export function Progress({
  value,
  label,
  showValue = false,
  srLabel,
  tone = 'accent',
  size = 'md',
  className,
}: ProgressProps) {
  const clamped = value === undefined ? undefined : Math.max(0, Math.min(100, Math.round(value)))

  return (
    <div className={cn('w-full', className)}>
      {(label || showValue) && (
        <div className="mb-1.5 flex items-baseline justify-between gap-3">
          {label ? <span className="text-xs font-medium text-ink">{label}</span> : <span />}
          {showValue && clamped !== undefined ? (
            <span className="font-mono text-xs text-ink-muted tabular-nums">{clamped}%</span>
          ) : null}
        </div>
      )}
      <div
        role="progressbar"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={clamped}
        aria-label={label ? undefined : (srLabel ?? 'Progress')}
        aria-valuetext={clamped === undefined ? 'In progress' : `${clamped}%`}
        className={cn(
          'w-full overflow-hidden rounded-full bg-surface-sunken',
          size === 'sm' ? 'h-1' : 'h-2',
        )}
      >
        {clamped === undefined ? (
          <div className={cn('h-full w-2/5 animate-pulse-token rounded-full', tones[tone])} />
        ) : (
          <div
            className={cn(
              'h-full rounded-full transition-[width] duration-300 ease-standard',
              tones[tone],
            )}
            style={{ width: `${clamped}%` }}
          />
        )}
      </div>
    </div>
  )
}

export interface ProgressRingProps {
  value: number
  size?: number
  label?: string
  tone?: keyof typeof tones
  className?: string
}

/** Circular variant, for dashboard tiles where a bar would waste width. */
export function ProgressRing({
  value,
  size = 56,
  label,
  tone = 'accent',
  className,
}: ProgressRingProps) {
  const clamped = Math.max(0, Math.min(100, Math.round(value)))
  const stroke = 5
  const radius = (size - stroke) / 2
  const circumference = 2 * Math.PI * radius
  const offset = circumference - (clamped / 100) * circumference
  const strokeColor = {
    accent: 'var(--color-accent)',
    success: 'var(--color-success)',
    warning: 'var(--color-warning)',
    danger: 'var(--color-danger)',
  }[tone]

  return (
    <div
      className={cn('relative inline-flex items-center justify-center', className)}
      role="progressbar"
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={clamped}
      aria-label={label ?? 'Progress'}
    >
      <svg width={size} height={size} className="-rotate-90" aria-hidden="true">
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke="var(--color-surface-sunken)"
          strokeWidth={stroke}
        />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          fill="none"
          stroke={strokeColor}
          strokeWidth={stroke}
          strokeLinecap="round"
          strokeDasharray={circumference}
          strokeDashoffset={offset}
        />
      </svg>
      <span className="absolute font-mono text-xs font-medium tabular-nums">{clamped}%</span>
    </div>
  )
}

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>
  )
}

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

Usage

A rollout tracker rather than a sales section. The honest answer includes what is not done, which is why one phase is deliberately in progress.

  • Never show a roadmap where everything is complete; nobody believes it.
  • Pair every progress bar with a status word.

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.

  • Four phases
  • Per-phase progress
  • Status pills

Accessibility

Progressbar roles
Each phase exposes a real progress value.
Status pills
State is carried by icon and word, not colour alone.

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.