Setup checklist
In-product onboarding whose progress is computed from the items.
Live preview
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.
Related
All sectionsDashboard quick actions
The four most common tasks, as full-card links.
starter3 variantsOnboarding wizard
Four steps ordered to minimise abandonment, with a skippable invite step and a review screen.
advancedFeatured4 variantsProgress
Determinate and indeterminate progress as a bar or a ring, with four tones.
starter5 variants