Setup Checklist
An onboarding checklist: ticks draw in, a ring fills with each step, and the title turns to “All set”.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/setup-checklist.json'use client'
import { useId, useState } from 'react'
import './setup-checklist.css'
export interface ChecklistItem {
id: string
label: string
done?: boolean
}
export interface SetupChecklistProps {
title?: string
items: ChecklistItem[]
/** Controlled done ids. */
done?: string[]
onDoneChange?: (ids: string[]) => void
progress?: 'ring' | 'bar'
density?: 'comfy' | 'compact'
doneTitle?: string
fill?: 'dark' | 'light'
className?: string
}
/** An onboarding checklist: tick a step and the ring fills; finish them all and the title says so. */
export function SetupChecklist({ title = 'Get started', items, done, onDoneChange, progress = 'ring', density = 'comfy', doneTitle = 'All set', fill = 'dark', className }: SetupChecklistProps) {
const [own, setOwn] = useState<string[]>(() => items.filter((i) => i.done).map((i) => i.id))
const ids = done ?? own
const id = useId()
const count = items.filter((i) => ids.includes(i.id)).length
const t = items.length ? count / items.length : 0
const all = count === items.length && items.length > 0
const toggle = (k: string) => {
const next = ids.includes(k) ? ids.filter((x) => x !== k) : [...ids, k]
if (done === undefined) setOwn(next)
onDoneChange?.(next)
}
const C = 2 * Math.PI * 15
return (
<section className={['sd-sc', className].filter(Boolean).join(' ')} data-fill={fill} data-density={density} data-all={all || undefined} aria-labelledby={`${id}-t`}>
<header className="sd-sc__head">
<div>
<h3 id={`${id}-t`} key={String(all)} className="sd-sc__title">
{all ? doneTitle : title}
</h3>
<p className="sd-sc__sub">
{count} of {items.length} done
</p>
</div>
{progress === 'ring' ? (
<svg className="sd-sc__ring" viewBox="0 0 36 36" role="progressbar" aria-label="Setup progress" aria-valuemin={0} aria-valuemax={items.length} aria-valuenow={count}>
<circle cx="18" cy="18" r="15" className="sd-sc__track" />
<circle cx="18" cy="18" r="15" className="sd-sc__arc" strokeDasharray={C} strokeDashoffset={C * (1 - t)} />
{all && <path d="M12.5 18.5l3.6 3.4 7.4-7.6" className="sd-sc__tick" />}
</svg>
) : (
<span className="sd-sc__count" aria-hidden>
{Math.round(t * 100)}%
</span>
)}
</header>
{progress === 'bar' && (
<div className="sd-sc__bar" role="progressbar" aria-label="Setup progress" aria-valuemin={0} aria-valuemax={items.length} aria-valuenow={count}>
<span style={{ transform: `scaleX(${t})` }} />
</div>
)}
<ul className="sd-sc__list">
{items.map((it) => {
const on = ids.includes(it.id)
return (
<li key={it.id}>
<label className="sd-sc__item" data-on={on || undefined}>
<input type="checkbox" checked={on} onChange={() => toggle(it.id)} />
<span className="sd-sc__box" aria-hidden>
<svg viewBox="0 0 16 16">
<path d="M4 8.4l2.6 2.4L12 5.4" />
</svg>
</span>
<span className="sd-sc__label">{it.label}</span>
</label>
</li>
)
})}
</ul>
</section>
)
}.sd-sc { --bg: #1d1d1d; --fg: #f7f7f7; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.09); --soft: rgb(255 255 255 / 0.07); --gap: 4px; --row: 36px;
width: min(100%, 290px); padding: 16px; border-radius: 22px; background: var(--bg); color: var(--fg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-sc[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.08); --soft: rgb(0 0 0 / 0.05); }
.sd-sc[data-density='compact'] { --gap: 2px; --row: 32px; }
.sd-sc__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 2px 2px 0; }
.sd-sc__title { margin: 0; font-size: 15px; font-weight: 500; letter-spacing: -0.01em; animation: sd-sc-in 360ms cubic-bezier(0.2, 1, 0.3, 1); }
.sd-sc__sub { margin: 2px 0 0; font-size: 12.5px; color: var(--mute); font-feature-settings: 'tnum'; }
.sd-sc__ring { width: 36px; height: 36px; flex: none; transform: rotate(-90deg); }
.sd-sc__track, .sd-sc__arc { fill: none; stroke-width: 3; }
.sd-sc__track { stroke: var(--soft); }
.sd-sc__arc { stroke: var(--fg); stroke-linecap: round; transition: stroke-dashoffset 600ms cubic-bezier(0.2, 1, 0.3, 1); }
.sd-sc__tick { fill: none; stroke: var(--fg); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; transform: rotate(90deg); transform-origin: 18px 18px; animation: sd-sc-pop 420ms cubic-bezier(0.2, 1.5, 0.4, 1); }
.sd-sc__count { font-size: 13px; color: var(--mute); font-feature-settings: 'tnum'; }
.sd-sc__bar { height: 4px; margin: 12px 2px 0; border-radius: 99px; background: var(--soft); overflow: hidden; }
.sd-sc__bar span { display: block; height: 100%; background: var(--fg); transform-origin: left; transition: transform 600ms cubic-bezier(0.2, 1, 0.3, 1); }
.sd-sc__list { display: grid; gap: var(--gap); margin: 12px 0 0; padding: 0; list-style: none; }
.sd-sc__item { position: relative; display: flex; align-items: center; gap: 10px; height: var(--row); padding: 0 10px; border-radius: 12px; cursor: pointer; transition: background-color 200ms; }
.sd-sc__item:hover { background: var(--soft); }
.sd-sc__item input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.sd-sc__box { display: grid; place-items: center; width: 18px; height: 18px; flex: none; border-radius: 999px; box-shadow: inset 0 0 0 1.5px var(--mute); transition: background-color 220ms, box-shadow 220ms; }
.sd-sc__box svg { width: 12px; height: 12px; fill: none; stroke: var(--bg); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 14; stroke-dashoffset: 14; transition: stroke-dashoffset 320ms 80ms cubic-bezier(0.2, 1, 0.3, 1); }
.sd-sc__item[data-on] .sd-sc__box { background: var(--fg); box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-sc__item[data-on] .sd-sc__box svg { stroke-dashoffset: 0; }
.sd-sc__label { font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color 220ms; }
.sd-sc__item[data-on] .sd-sc__label { color: var(--mute); text-decoration: line-through; text-decoration-color: color-mix(in srgb, var(--mute) 60%, transparent); }
.sd-sc__item:has(input:focus-visible) { outline: 2px solid var(--fg); outline-offset: 1px; }
@keyframes sd-sc-in { from { opacity: 0; transform: translateY(4px); } }
@keyframes sd-sc-pop { from { opacity: 0; transform: rotate(90deg) scale(0.4); } }
@media (prefers-reduced-motion: reduce) { .sd-sc * { animation: none !important; transition: none !important; } }Overview
Each row is a real checkbox. Ticking one draws the check, strikes the label through softly and advances the progress ring in the header.
When every step is done the ring closes with a check and the title changes, so the card can stay on screen as a quiet confirmation.
When to use
- First-run setup in a product: a handful of steps people finish over a few sessions.
- Launch or release checklists in a sidebar.
When not to use
- Long to-do lists: use a full task list with sorting.
- Steps that must happen in order: use a stepper.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/setup-checklist.json$
npx shadcn@latest add https://sundaeui.dev/r/setup-checklist.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/setup-checklist.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/setup-checklist.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy setup-checklist.tsx and setup-checklist.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { SetupChecklist } from '@/components/sundae/setup-checklist'
<SetupChecklist
items={[
{ id: 'profile', label: 'Add your profile photo', done: true },
{ id: 'team', label: 'Invite two teammates' },
]}
onDoneChange={save}
/>Props
| Prop | Type | Default |
|---|---|---|
items*id, label and optional starting done state. | ChecklistItem[] | — |
doneControlled list of done ids. | string[] | — |
onDoneChangeCalled on every toggle. | (ids: string[]) => void | — |
title / doneTitleHeader text before and after finishing. | string | 'Get started' / 'All set' |
progressProgress style. | 'ring' | 'bar' | 'ring' |
densityRow height. | 'comfy' | 'compact' | 'comfy' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Rows are native checkboxes inside labels, so Space toggles and screen readers report state.
- Progress is a role="progressbar" with the done count as its value.
- Reduced motion removes the draw-in, ring sweep and title fade.
Customization
- --row and --gap control density.
- Style done rows through [data-on] on .sd-sc__item.
Keyboard
- Tab
- Move between steps
- Space
- Tick or untick a step
FAQ
- Can a step link somewhere instead of ticking?
- Yes. Replace the label text with a link and tick the item from your own code once the task is really done, using the done prop.
- How many steps fit?
- Four to six reads best in a card. Past that, group them.
- Is Setup Checklist free?
- Yes. Setup Checklist is free for personal and commercial projects, with full source on this page.
- How do I install Setup Checklist?
- Run npx shadcn@latest add https://sundaeui.dev/r/setup-checklist.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Setup Checklist support keyboard and screen readers?
- Rows are native checkboxes inside labels, so Space toggles and screen readers report state. Progress is a role="progressbar" with the done count as its value.
- Does Setup Checklist respect reduced motion?
- Reduced motion removes the draw-in, ring sweep and title fade.
More like this
All components →Agent Steps
Components · UnderstatedDownload Button
Components · Refined- Pro
Story Viewer
Components · Refined Task List
Components · RefinedFold Accordion
Components · UnderstatedConfirm Dialog
Components · Understated