# Onboarding Checklist

> Onboarding checklist with a progress bar, expandable steps, inline actions and a completed state.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/onboarding-checklist
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/onboarding-checklist.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/onboarding-checklist-demo.tsx"
"use client"

import { OnboardingChecklist } from "@/components/onboarding-checklist"

const items = [
  { id: "profile", title: "Complete your profile", description: "Add a photo and a short bio so teammates know who you are.", actionLabel: "Edit profile" },
  { id: "invite", title: "Invite your team", description: "Projects are better together. Invite up to 5 teammates for free.", actionLabel: "Send invites" },
  { id: "project", title: "Create your first project", description: "Start from a template or from scratch. You can change everything later.", actionLabel: "New project" },
  { id: "integrate", title: "Connect an integration", description: "Sync with Slack, GitHub or Linear in two clicks.", actionLabel: "Browse integrations" },
]

export default function OnboardingChecklistDemo() {
  return <OnboardingChecklist items={items} defaultCompleted={["profile"]} />
}
```

## Source

```tsx title="components/onboarding-checklist.tsx"
"use client"

import * as React from "react"
import { Check, ChevronDown, PartyPopper } from "lucide-react"

import { cn } from "@/lib/utils"

export interface ChecklistItem {
  id: string
  title: string
  description: string
  actionLabel?: string
}

export interface OnboardingChecklistProps extends Omit<React.ComponentProps<"div">, "onChange"> {
  title?: string
  items: ChecklistItem[]
  defaultCompleted?: string[]
  onCompletedChange?: (completed: string[]) => void
}

export function OnboardingChecklist({
  title = "Get started",
  items,
  defaultCompleted = [],
  onCompletedChange,
  className,
  ...props
}: OnboardingChecklistProps) {
  const [completed, setCompleted] = React.useState(defaultCompleted)
  const firstPending = items.find((item) => !completed.includes(item.id))?.id ?? null
  const [openId, setOpenId] = React.useState<string | null>(firstPending)
  const done = completed.length
  const percent = items.length ? (done / items.length) * 100 : 0
  const finished = done === items.length

  const toggle = (id: string) => {
    const next = completed.includes(id) ? completed.filter((c) => c !== id) : [...completed, id]
    setCompleted(next)
    onCompletedChange?.(next)
    if (!completed.includes(id)) {
      const upcoming = items.find((item) => item.id !== id && !next.includes(item.id))
      setOpenId(upcoming?.id ?? null)
    }
  }

  return (
    <div {...props} className={cn("w-full max-w-md overflow-hidden rounded-2xl border bg-card", className)}>
      <div className="p-5 pb-4">
        <div className="flex items-center justify-between">
          <h3 className="flex items-center gap-2 text-base font-semibold">
            {finished && <PartyPopper aria-hidden className="size-4 text-amber-500" />}
            {finished ? "You're all set" : title}
          </h3>
          <span className="text-sm tabular-nums text-muted-foreground" data-testid="progress-label">
            {done} / {items.length}
          </span>
        </div>
        <div
          role="progressbar"
          aria-label="Setup progress"
          aria-valuemin={0}
          aria-valuemax={items.length}
          aria-valuenow={done}
          className="mt-3 h-1.5 overflow-hidden rounded-full bg-muted"
        >
          <div className={cn("h-full rounded-full transition-[width] duration-500 ease-out", finished ? "bg-emerald-500" : "bg-primary")} style={{ width: `${percent}%` }} />
        </div>
      </div>

      <ul className="divide-y border-t">
        {items.map((item) => {
          const isDone = completed.includes(item.id)
          const open = openId === item.id
          const panelId = `check-panel-${item.id}`
          return (
            <li key={item.id}>
              <div className="flex items-center gap-3 px-5 py-3">
                <button
                  type="button"
                  role="checkbox"
                  aria-checked={isDone}
                  aria-label={`Mark "${item.title}" as ${isDone ? "not done" : "done"}`}
                  onClick={() => toggle(item.id)}
                  className={cn(
                    "flex size-5 shrink-0 items-center justify-center rounded-full border-2 outline-none transition-all focus-visible:ring-4 focus-visible:ring-ring/40",
                    isDone ? "border-emerald-500 bg-emerald-500 text-white" : "border-muted-foreground/40 hover:border-foreground",
                  )}
                >
                  {isDone && <Check className="size-3" strokeWidth={3.5} />}
                </button>
                <button
                  type="button"
                  aria-expanded={open}
                  aria-controls={panelId}
                  onClick={() => setOpenId(open ? null : item.id)}
                  className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded text-left text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring"
                >
                  <span className={cn("truncate transition-colors", isDone && "text-muted-foreground line-through")}>{item.title}</span>
                  <ChevronDown className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
                </button>
              </div>
              <div id={panelId} role="region" hidden={!open} className="px-5 pb-4 pl-13">
                <p className="text-sm leading-relaxed text-muted-foreground">{item.description}</p>
                {item.actionLabel && !isDone && (
                  <button
                    type="button"
                    onClick={() => toggle(item.id)}
                    className="mt-3 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
                  >
                    {item.actionLabel}
                  </button>
                )}
              </div>
            </li>
          )
        })}
      </ul>
    </div>
  )
}
```
