Skip to content

Setup checklist

In-product onboarding whose progress is computed from the items.

Applicationstarterdashboardonboardingchecklistprogress

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 Link from 'next/link'
import { ArrowRight, Check } from 'lucide-react'
import { Panel } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/cn'

/**
 * Setup checklist
 *
 * The in-product onboarding panel. The progress figure is computed from the
 * items rather than stored separately, so it can never disagree with the list —
 * the bug every hand-maintained checklist eventually has.
 */
const steps = [
  {
    title: 'Create a workspace',
    description: 'Name, slug and data region.',
    done: true,
    href: '/forms/customer-onboarding',
  },
  {
    title: 'Connect a repository',
    description: 'Builds run on every push.',
    done: true,
    href: '/forms/setup-wizard',
  },
  {
    title: 'Invite your team',
    description: 'Members can view and edit projects.',
    done: true,
    href: '/forms/onboarding',
  },
  {
    title: 'Set up notifications',
    description: 'Choose what reaches you, and where.',
    done: false,
    href: '/forms/notifications',
  },
  {
    title: 'Enable two-factor',
    description: 'Required before production access.',
    done: false,
    href: '/forms/security-settings',
  },
]

export default function DashboardOnboardingChecklist() {
  const complete = steps.filter((step) => step.done).length
  const percent = Math.round((complete / steps.length) * 100)

  return (
    <section className="bg-canvas p-4 sm:p-6">
      <Panel
        title="Finish setting up"
        description={`${complete} of ${steps.length} complete`}
        headingLevel="h2"
        action={<Badge tone={percent === 100 ? 'success' : 'accent'}>{percent}%</Badge>}
      >
        <Progress value={percent} srLabel="Setup progress" />

        <ul className="mt-5 flex flex-col gap-2">
          {steps.map((step) => (
            <li key={step.title}>
              <Link
                href={step.href}
                className={cn(
                  'group flex items-start gap-3 rounded-lg border p-3.5 transition-colors',
                  step.done
                    ? 'border-line-subtle bg-surface-sunken'
                    : 'border-line bg-surface hover:border-accent',
                )}
              >
                <span
                  className={cn(
                    'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border',
                    step.done ? 'border-success bg-success text-white' : 'border-line-strong',
                  )}
                  aria-hidden="true"
                >
                  {step.done ? <Check className="size-3" strokeWidth={3} /> : null}
                </span>
                <span className="min-w-0 flex-1">
                  <span
                    className={cn(
                      'block text-sm font-medium',
                      step.done
                        ? 'text-ink-muted line-through'
                        : 'text-ink-strong group-hover:text-accent',
                    )}
                  >
                    {step.title}
                    <span className="sr-only">{step.done ? ' — completed' : ' — not started'}</span>
                  </span>
                  <span className="mt-0.5 block text-xs text-ink-muted">{step.description}</span>
                </span>
                {!step.done ? (
                  <ArrowRight
                    className="size-4 shrink-0 text-ink-subtle transition-transform group-hover:translate-x-0.5"
                    aria-hidden="true"
                  />
                ) : null}
              </Link>
            </li>
          ))}
        </ul>
      </Panel>
    </section>
  )
}

components/blocks/sections/dashboard/onboarding-checklist.tsx

import Link from 'next/link'
import { ArrowRight, Check } from 'lucide-react'
import { Panel } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/cn'

/**
 * Setup checklist
 *
 * The in-product onboarding panel. The progress figure is computed from the
 * items rather than stored separately, so it can never disagree with the list —
 * the bug every hand-maintained checklist eventually has.
 */
const steps = [
  {
    title: 'Create a workspace',
    description: 'Name, slug and data region.',
    done: true,
    href: '/forms/customer-onboarding',
  },
  {
    title: 'Connect a repository',
    description: 'Builds run on every push.',
    done: true,
    href: '/forms/setup-wizard',
  },
  {
    title: 'Invite your team',
    description: 'Members can view and edit projects.',
    done: true,
    href: '/forms/onboarding',
  },
  {
    title: 'Set up notifications',
    description: 'Choose what reaches you, and where.',
    done: false,
    href: '/forms/notifications',
  },
  {
    title: 'Enable two-factor',
    description: 'Required before production access.',
    done: false,
    href: '/forms/security-settings',
  },
]

export default function DashboardOnboardingChecklist() {
  const complete = steps.filter((step) => step.done).length
  const percent = Math.round((complete / steps.length) * 100)

  return (
    <section className="bg-canvas p-4 sm:p-6">
      <Panel
        title="Finish setting up"
        description={`${complete} of ${steps.length} complete`}
        headingLevel="h2"
        action={<Badge tone={percent === 100 ? 'success' : 'accent'}>{percent}%</Badge>}
      >
        <Progress value={percent} srLabel="Setup progress" />

        <ul className="mt-5 flex flex-col gap-2">
          {steps.map((step) => (
            <li key={step.title}>
              <Link
                href={step.href}
                className={cn(
                  'group flex items-start gap-3 rounded-lg border p-3.5 transition-colors',
                  step.done
                    ? 'border-line-subtle bg-surface-sunken'
                    : 'border-line bg-surface hover:border-accent',
                )}
              >
                <span
                  className={cn(
                    'mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border',
                    step.done ? 'border-success bg-success text-white' : 'border-line-strong',
                  )}
                  aria-hidden="true"
                >
                  {step.done ? <Check className="size-3" strokeWidth={3} /> : null}
                </span>
                <span className="min-w-0 flex-1">
                  <span
                    className={cn(
                      'block text-sm font-medium',
                      step.done
                        ? 'text-ink-muted line-through'
                        : 'text-ink-strong group-hover:text-accent',
                    )}
                  >
                    {step.title}
                    <span className="sr-only">{step.done ? ' — completed' : ' — not started'}</span>
                  </span>
                  <span className="mt-0.5 block text-xs text-ink-muted">{step.description}</span>
                </span>
                {!step.done ? (
                  <ArrowRight
                    className="size-4 shrink-0 text-ink-subtle transition-transform group-hover:translate-x-0.5"
                    aria-hidden="true"
                  />
                ) : null}
              </Link>
            </li>
          ))}
        </ul>
      </Panel>
    </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>
  )
}

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

Usage

The progress figure is computed from the items rather than stored separately, so it can never disagree with the list — the bug every hand-maintained checklist eventually has.

  • Compute the percentage; never store it alongside the items.
  • Keep completed items visible so the progress feels earned.

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.

  • Five steps
  • Computed progress
  • Completed and pending states

Accessibility

State in text
Each step announces 'completed' or 'not started' as hidden text.
Named progress
The bar has an accessible name without duplicating visible copy.

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.