# Onboarding Checklist

> First-run setup with a true progress count: a list as its own screen, a slim row above the page, or welcome steps one at a time in a dialog.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/onboarding-checklist).

- Page, with a live preview: https://shadcn.exchange/r/miseui/onboarding-checklist
- Screenshot: https://shadcn.exchange/shot/miseui/onboarding-checklist/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/onboarding-checklist.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

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

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/checkbox.json`, `https://miseui.junsu4621.workers.dev/r/dialog.json`, `https://miseui.junsu4621.workers.dev/r/progress.json`

## Files

### `registry/miseui/blocks/onboarding-checklist.tsx`

```tsx
import type * as React from "react"
import { Landmark, Send, Store, UserPlus } from "lucide-react"
import { OnboardingBanner } from "./onboarding-checklist-banner"
import { OnboardingDialog } from "./onboarding-checklist-dialog"
import { OnboardingPanel } from "./onboarding-checklist-panel"
import type { OnboardingStep } from "./onboarding-checklist-state"

type OnboardingChecklistVariant = "panel" | "banner" | "dialog"

const DEFAULT_STEPS: readonly OnboardingStep[] = [
  { id: "workspace", title: "Name your workspace", description: "It appears on every invoice you send.", actionLabel: "Name it", icon: Store },
  { id: "bank", title: "Connect your bank", description: "Payments land here, and you can change it later.", actionLabel: "Connect", icon: Landmark },
  { id: "invoice", title: "Send a test invoice", description: "It goes only to you, so nothing real moves.", actionLabel: "Send a test", icon: Send },
  { id: "invite", title: "Invite your accountant", description: "They can read the books and cannot move money.", actionLabel: "Invite", icon: UserPlus },
]

const NOTHING_DONE: readonly string[] = []

interface OnboardingChecklistProps extends Omit<React.ComponentProps<"section">, "title"> {
  /** `panel` the list as its own screen, `banner` a slim row above your page, `dialog` one step at a time on a first visit. Anything else is `panel`. */
  variant?: OnboardingChecklistVariant
  title?: string
  /** Continues the title, one tone quieter. On `banner` it is shown once every step is done. */
  description?: string
  steps?: readonly OnboardingStep[]
  /** Ids of finished steps. Pass it to decide yourself when a step counts as done; leave it out and pressing a step's button ticks it. */
  completed?: readonly string[]
  /** What is really done already. Progress starts at this count and never above it, so do not seed it to flatter the bar. */
  defaultCompleted?: readonly string[]
  onCompletedChange?: (completed: readonly string[]) => void
  /** Runs when a step's button is pressed. Return a promise to get the loading state; a rejection's message is shown beside the step. */
  onStep?: (id: string) => void | Promise<void>
  /** The button once everything is done. */
  finishLabel?: string
  onFinish?: () => void
  /** Hiding is remembered by you: save it and stop rendering this. `panel` only offers the button when this is passed. */
  onDismiss?: () => void
  /** `dialog` only. */
  open?: boolean
  /** `dialog` only. A welcome greets on arrival, so it starts open. */
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
}

function OnboardingChecklist({
  variant,
  title = "Finish setting up Paperline.",
  description = "A few minutes, in any order.",
  steps = DEFAULT_STEPS,
  completed,
  defaultCompleted = NOTHING_DONE,
  finishLabel = "Open Paperline",
  open,
  defaultOpen,
  onOpenChange,
  ...props
}: OnboardingChecklistProps) {
  const shared = { title, description, steps, completed, defaultCompleted, finishLabel, ...props }
  if (variant === "banner") return <OnboardingBanner {...shared} />
  if (variant === "dialog") return <OnboardingDialog {...shared} open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} />
  return <OnboardingPanel {...shared} />
}

export { OnboardingChecklist, type OnboardingChecklistProps, type OnboardingChecklistVariant, type OnboardingStep }
```

### `registry/miseui/blocks/onboarding-checklist-banner.tsx`

```tsx
import { useRef, useState } from "react"
import { Loader2, X } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Progress } from "@/registry/miseui/ui/progress"
import { INK_FILL_CLASS, type OnboardingViewProps } from "./onboarding-checklist-panel"
import { useOnboarding, type Onboarding } from "./onboarding-checklist-state"

function BannerText({ onboarding, finishNote }: { onboarding: Onboarding; finishNote: string }) {
  return (
    <div className="min-w-0 @2xl:flex-1">
      <p role="status" className="text-[13px] leading-5 text-pretty">
        <span className="font-medium tabular-nums">
          {onboarding.doneCount} of {onboarding.total} done.
        </span>{" "}
        <span className="text-muted-foreground">{onboarding.next ? `Next: ${onboarding.next.title}` : finishNote}</span>
      </p>
      {onboarding.failure ? (
        <p role="alert" className="text-[13px] leading-5 text-pretty text-destructive">
          {onboarding.failure.message}
        </p>
      ) : null}
    </div>
  )
}

/** A row of its own at the top of the page: the work stays the main thing, and the next step is one press away. */
function OnboardingBanner({ steps, completed, defaultCompleted, onCompletedChange, onStep, finishLabel, onFinish, onDismiss, title, description, className, children, ...props }: OnboardingViewProps) {
  const onboarding = useOnboarding({ steps, completed, defaultCompleted, onCompletedChange, onStep })
  const [hidden, setHidden] = useState(false)
  const rootRef = useRef<HTMLElement>(null)
  const next = onboarding.next
  const pending = next !== undefined && onboarding.pendingId === next.id

  const hide = () => {
    setHidden(true)
    onDismiss?.()
    // The button that had focus just left with the row. The page below is where they are going.
    rootRef.current?.focus()
  }

  return (
    <section ref={rootRef} tabIndex={-1} data-slot="onboarding-checklist" data-variant="banner" className={cn("@container w-full outline-none", className)} {...props}>
      {hidden ? null : (
        <div role="region" aria-label={title} data-slot="onboarding-banner" className="relative border-b bg-background">
          <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 gap-y-1 py-2 ps-4 pe-2 @2xl:flex @2xl:gap-4 @2xl:pe-3">
            <BannerText onboarding={onboarding} finishNote={description} />
            {next ? (
              <Button
                variant="mise"
                size="sm"
                aria-busy={pending || undefined}
                aria-disabled={pending || undefined}
                onClick={() => void onboarding.press(next.id)}
                className="col-start-1 row-start-2 justify-self-start pointer-coarse:h-10"
              >
                {pending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
                {next.actionLabel}
              </Button>
            ) : (
              <Button
                variant="mise"
                size="sm"
                onClick={() => {
                  onFinish?.()
                  hide()
                }}
                className="col-start-1 row-start-2 justify-self-start pointer-coarse:h-10"
              >
                {finishLabel}
              </Button>
            )}
            <Button variant="ghost" size="icon" aria-label="Hide setup for now" onClick={hide} className="col-start-2 row-start-1 size-8 pointer-coarse:size-10">
              <X aria-hidden="true" />
            </Button>
          </div>
          {/* The row's own bottom edge is the bar, so progress takes no height from the page. */}
          <Progress variant="thin" effect="spring" label="Setup progress" value={onboarding.doneCount} max={Math.max(onboarding.total, 1)} className={cn("absolute inset-x-0 -bottom-px rounded-none", INK_FILL_CLASS)} />
        </div>
      )}
      {children}
    </section>
  )
}

export { OnboardingBanner }
```

### `registry/miseui/blocks/onboarding-checklist-dialog.tsx`

```tsx
import { useEffect, useRef, useState } from "react"
import { Check, Hand, Loader2, X } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/registry/miseui/ui/dialog"
import { Progress } from "@/registry/miseui/ui/progress"
import type { OnboardingViewProps } from "./onboarding-checklist-panel"
import { useOnboarding, type Onboarding, type OnboardingStep } from "./onboarding-checklist-state"

interface OnboardingDialogProps extends OnboardingViewProps {
  /** A welcome is meant to greet people on arrival, so it starts open. Pass `open` to decide from your own first-visit flag. */
  open?: boolean
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
}

/** Screen 0 greets, then one screen per step, then the closing one. */
const WELCOME_SCREEN = 0

function nextOpenScreen(from: number, steps: readonly OnboardingStep[], done: readonly string[]) {
  const ahead = steps.findIndex((step, index) => index + 1 > from && !done.includes(step.id))
  return ahead === -1 ? steps.length + 1 : ahead + 1
}

interface ScreenCopy {
  title: string
  description: string
  icon: NonNullable<OnboardingStep["icon"]>
}

function screenCopy(screen: number, step: OnboardingStep | undefined, welcome: Pick<ScreenCopy, "title" | "description">, onboarding: Onboarding): ScreenCopy {
  if (screen === WELCOME_SCREEN) return { ...welcome, icon: Hand }
  if (step) return { title: step.title, description: step.description, icon: step.icon ?? Hand }
  const everything = onboarding.doneCount === onboarding.total
  return {
    title: everything ? "That is everything." : `${onboarding.doneCount} of ${onboarding.total} done.`,
    description: everything ? "You are ready to start." : "The rest waits behind the Setup button.",
    icon: Check,
  }
}

interface FooterProps {
  screen: number
  step: OnboardingStep | undefined
  onboarding: Onboarding
  finishLabel: string
  onSkip: () => void
  onPrimary: () => void
}

function ScreenFooter({ screen, step, onboarding, finishLabel, onSkip, onPrimary }: FooterProps) {
  const welcome = screen === WELCOME_SCREEN
  const closing = !welcome && !step
  const pending = step !== undefined && onboarding.pendingId === step.id
  return (
    <div className="flex flex-wrap items-center justify-end gap-2 px-6 pb-6">
      {closing ? null : (
        <Button variant="ghost" onClick={onSkip} className="pointer-coarse:h-10">
          Not now
        </Button>
      )}
      <Button variant="mise" aria-busy={pending || undefined} aria-disabled={pending || undefined} onClick={onPrimary} className="pointer-coarse:h-10">
        {pending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
        {welcome ? "Start" : (step?.actionLabel ?? finishLabel)}
      </Button>
    </div>
  )
}

function ScreenTop({ onboarding }: { onboarding: Onboarding }) {
  const cells = Math.max(onboarding.total, 1)
  return (
    <div className="flex items-center gap-3 ps-6 pe-2 pt-2">
      <p role="status" className="sr-only">
        {onboarding.doneCount} of {onboarding.total} done
      </p>
      {/* In ink: the one accent in this dialog belongs to the button that moves things on. */}
      <Progress variant="segmented" effect="spring" label="Setup progress" value={onboarding.doneCount} max={cells} steps={cells} className="flex-1 [&_[data-slot=progress-segment]>span]:bg-foreground" />
      <DialogClose asChild>
        <Button variant="ghost" size="icon" aria-label="Close" className="size-10">
          <X aria-hidden="true" />
        </Button>
      </DialogClose>
    </div>
  )
}

interface WelcomeDialogProps extends Pick<OnboardingDialogProps, "steps" | "title" | "description" | "finishLabel" | "onFinish"> {
  onboarding: Onboarding
  onClose: () => void
}

function WelcomeDialog({ steps, title, description, finishLabel, onFinish, onboarding, onClose }: WelcomeDialogProps) {
  const [screen, setScreen] = useState(WELCOME_SCREEN)
  const titleRef = useRef<HTMLHeadingElement>(null)
  const step = steps[screen - 1]
  const copy = screenCopy(screen, step, { title, description }, onboarding)
  const Icon = copy.icon
  // The title takes focus on each new screen, so a screen reader hears the step and not silence.
  useEffect(() => {
    if (screen !== WELCOME_SCREEN) titleRef.current?.focus()
  }, [screen])

  const pressStep = async (id: string) => {
    const index = steps.findIndex((item) => item.id === id)
    if (await onboarding.press(id)) setScreen(nextOpenScreen(index + 1, steps, [...onboarding.done, id]))
  }
  const primary = () => {
    if (screen === WELCOME_SCREEN) return setScreen(nextOpenScreen(0, steps, onboarding.done))
    if (step) return void pressStep(step.id)
    onFinish?.()
    onClose()
  }
  const skip = () => (screen === WELCOME_SCREEN ? onClose() : setScreen(nextOpenScreen(screen, steps, onboarding.done)))

  return (
    <DialogContent
      showCloseButton={false}
      aria-describedby={undefined}
      onOpenAutoFocus={(event) => {
        event.preventDefault()
        titleRef.current?.focus()
      }}
      className="gap-0 rounded-2xl p-0 sm:max-w-md"
    >
      <ScreenTop onboarding={onboarding} />
      <div className="flex flex-col gap-4 px-6 pt-6 pb-8">
        <span aria-hidden="true" className="grid size-12 place-items-center rounded-2xl bg-muted text-muted-foreground">
          <Icon className="size-6" />
        </span>
        <DialogTitle ref={titleRef} tabIndex={-1} className="text-display text-2xl leading-[1.12]! text-balance outline-none">
          {copy.title} <span className="block text-muted-foreground">{copy.description}</span>
        </DialogTitle>
        {onboarding.failure ? (
          <p role="alert" className="text-[13px] leading-snug text-pretty text-destructive">
            {onboarding.failure.message}
          </p>
        ) : null}
      </div>
      <ScreenFooter screen={screen} step={step} onboarding={onboarding} finishLabel={finishLabel} onSkip={skip} onPrimary={primary} />
    </DialogContent>
  )
}

/** One thing at a time, for a first visit where a whole list would read as homework. Closing it loses nothing: the Setup button stays on the page. */
function OnboardingDialog({ steps, completed, defaultCompleted, onCompletedChange, onStep, title, description, finishLabel, onFinish, onDismiss, open, defaultOpen = true, onOpenChange, className, children, ...props }: OnboardingDialogProps) {
  const onboarding = useOnboarding({ steps, completed, defaultCompleted, onCompletedChange, onStep })
  const [uncontrolled, setUncontrolled] = useState(defaultOpen)
  const setOpen = (next: boolean) => {
    setUncontrolled(next)
    onOpenChange?.(next)
    if (!next) onDismiss?.()
  }
  return (
    <section data-slot="onboarding-checklist" data-variant="dialog" aria-label="Setup" className={cn("@container relative min-h-[640px] w-full", className)} {...props}>
      {children}
      <Dialog open={open ?? uncontrolled} onOpenChange={setOpen}>
        <DialogTrigger asChild>
          <Button variant="outline" className="absolute end-4 bottom-4 tabular-nums shadow-md pointer-coarse:h-10 dark:shadow-black/40">
            Setup, {onboarding.doneCount} of {onboarding.total} done
          </Button>
        </DialogTrigger>
        <WelcomeDialog {...{ steps, title, description, finishLabel, onFinish, onboarding }} onClose={() => setOpen(false)} />
      </Dialog>
    </section>
  )
}

export { nextOpenScreen, OnboardingDialog, type OnboardingDialogProps }
```

### `registry/miseui/blocks/onboarding-checklist-panel.tsx`

```tsx
import type * as React from "react"
import { useEffect, useId, useRef, useState } from "react"
import { ChevronDown, Loader2 } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Checkbox } from "@/registry/miseui/ui/checkbox"
import { Progress } from "@/registry/miseui/ui/progress"
import { useOnboarding, type Onboarding, type OnboardingOptions, type OnboardingStep } from "./onboarding-checklist-state"

const FOCUS_NEXT_SELECTOR = "[data-focus-next]"
/** In ink: the one accent on this screen belongs to the next thing to press. */
const INK_FILL_CLASS = "[&_[data-slot=progress-fill]]:bg-foreground"

interface OnboardingViewProps extends Omit<React.ComponentProps<"section">, "title">, OnboardingOptions {
  title: string
  description: string
  finishLabel: string
  onFinish?: () => void
  onDismiss?: () => void
}

interface StepRowProps {
  step: OnboardingStep
  onboarding: Onboarding
  open: boolean
  onOpen: () => void
  onPress: () => void
}

function StepBody({ step, onboarding, onPress, id }: Pick<StepRowProps, "step" | "onboarding" | "onPress"> & { id: string }) {
  const next = onboarding.next?.id === step.id
  const pending = onboarding.pendingId === step.id
  const failed = onboarding.failure?.id === step.id
  return (
    <div id={id} className="flex flex-col items-start gap-3 pb-3">
      <p className="text-[13px] leading-relaxed text-pretty text-muted-foreground">{step.description}</p>
      {failed ? (
        <p role="alert" className="text-[13px] leading-snug text-pretty text-destructive">
          {onboarding.failure?.message}
        </p>
      ) : null}
      {/* aria-disabled and not disabled: a disabled button drops keyboard focus while the work runs. */}
      <Button
        variant={next ? "mise" : "outline"}
        data-focus-next={next ? "" : undefined}
        aria-busy={pending || undefined}
        aria-disabled={onboarding.pendingId !== null || undefined}
        onClick={onPress}
        className="pointer-coarse:h-10"
      >
        {pending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
        {step.actionLabel}
      </Button>
    </div>
  )
}

function StepRow({ step, onboarding, open, onOpen, onPress }: StepRowProps) {
  const bodyId = useId()
  const done = onboarding.done.includes(step.id)
  return (
    <li data-state={done ? "done" : open ? "open" : "closed"} className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-1 px-2 py-1 @3xl:px-3">
      {/* The label is the hit target: a 16 px box stays 16 px while the press area reaches 40. */}
      <label className="inline-flex size-10 cursor-pointer items-center justify-center self-start">
        <Checkbox aria-label={step.title} checked={done} disabled={onboarding.pendingId !== null} onCheckedChange={(state) => onboarding.setDone(step.id, state === true)} />
      </label>
      <div className="min-w-0 pe-2">
        {done ? (
          <p className="flex min-h-10 items-center text-[15px] text-pretty text-muted-foreground">{step.title}</p>
        ) : (
          <button
            type="button"
            aria-expanded={open}
            aria-controls={open ? bodyId : undefined}
            onClick={onOpen}
            className="flex min-h-10 w-full items-center justify-between gap-2 rounded-md text-start text-[15px] text-pretty outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
          >
            <span className="min-w-0">{step.title}</span>
            <ChevronDown aria-hidden="true" className={cn("size-4 shrink-0 text-muted-foreground transition-transform duration-(--duration-fast) ease-(--ease-mise) motion-reduce:transition-none", open && "rotate-180")} />
          </button>
        )}
        {open && !done ? <StepBody id={bodyId} step={step} onboarding={onboarding} onPress={onPress} /> : null}
      </div>
    </li>
  )
}

/** The pressed button goes away with its row, which would drop keyboard focus onto the page. */
function useFocusAfterStep(plate: React.RefObject<HTMLDivElement | null>, doneCount: number) {
  const pendingRef = useRef(false)
  useEffect(() => {
    if (!pendingRef.current) return
    pendingRef.current = false
    plate.current?.querySelector<HTMLElement>(FOCUS_NEXT_SELECTOR)?.focus()
  }, [doneCount, plate])
  return pendingRef
}

function StepsPlate({ steps, onboarding, finish }: { steps: readonly OnboardingStep[]; onboarding: Onboarding; finish: React.ReactNode }) {
  const plateRef = useRef<HTMLDivElement>(null)
  const pendingFocusRef = useFocusAfterStep(plateRef, onboarding.doneCount)
  const [chosenId, setChosenId] = useState<string | null>(null)
  const chosenOpen = chosenId !== null && !onboarding.done.includes(chosenId)
  const openId = chosenOpen ? chosenId : onboarding.next?.id

  const pressStep = async (id: string) => {
    pendingFocusRef.current = true
    const finished = await onboarding.press(id)
    // Failed: the row stays open, with its message, whichever row that is.
    if (finished) setChosenId(null)
    else pendingFocusRef.current = false
  }

  return (
    <div ref={plateRef} className="min-w-0 rounded-2xl border bg-background dark:bg-card">
      {steps.length > 0 ? (
        <ol className="flex flex-col divide-y">
          {steps.map((step) => (
            <StepRow
              key={step.id}
              step={step}
              onboarding={onboarding}
              open={openId === step.id}
              onOpen={() => setChosenId(openId === step.id ? "" : step.id)}
              onPress={() => void pressStep(step.id)}
            />
          ))}
        </ol>
      ) : null}
      {onboarding.allDone ? (
        <div className={cn("flex flex-wrap items-center justify-between gap-3 p-4", steps.length > 0 && "border-t")}>
          <p className="text-[15px]">That is everything.</p>
          {finish}
        </div>
      ) : null}
    </div>
  )
}

/** Heading and count stay beside the list on wide screens, so the end stays in sight while a step is open. */
function OnboardingPanel({ steps, completed, defaultCompleted, onCompletedChange, onStep, title, description, finishLabel, onFinish, onDismiss, className, ...props }: OnboardingViewProps) {
  const onboarding = useOnboarding({ steps, completed, defaultCompleted, onCompletedChange, onStep })
  const finish = (
    <Button variant="mise" data-focus-next="" onClick={onFinish} className="pointer-coarse:h-10">
      {finishLabel}
    </Button>
  )
  return (
    <section data-slot="onboarding-checklist" data-variant="panel" aria-label="Setup" className={cn("@container flex w-full flex-col bg-muted dark:bg-background", className)} {...props}>
      <div className="flex min-h-[640px] flex-1 items-center px-4 py-12 @3xl:px-8">
        <div className="mx-auto grid w-full max-w-4xl grid-cols-[minmax(0,1fr)] items-start gap-8 @3xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] @3xl:gap-12">
          <div className="flex min-w-0 flex-col gap-6">
            <h2 className="text-display text-3xl leading-[1.12]! text-balance">
              {title} <span className="text-muted-foreground">{description}</span>
            </h2>
            {onboarding.total > 0 ? (
              <div className="flex flex-col gap-2">
                <p role="status" className="text-[13px] text-muted-foreground tabular-nums">
                  {onboarding.doneCount} of {onboarding.total} done
                </p>
                <Progress label="Setup progress" effect="spring" value={onboarding.doneCount} max={onboarding.total} className={INK_FILL_CLASS} />
              </div>
            ) : null}
            {onDismiss ? (
              <Button variant="ghost" onClick={onDismiss} className="-ms-3 self-start text-muted-foreground pointer-coarse:h-10">
                Hide this for now
              </Button>
            ) : null}
          </div>
          <StepsPlate steps={steps} onboarding={onboarding} finish={finish} />
        </div>
      </div>
    </section>
  )
}

export { INK_FILL_CLASS, OnboardingPanel, type OnboardingViewProps }
```

### `registry/miseui/blocks/onboarding-checklist-state.ts`

```ts
import type * as React from "react"
import { useState } from "react"

interface OnboardingStep {
  id: string
  title: string
  /** Continues the title, one tone quieter. Say what the step gets them, not how the screen works. */
  description: string
  actionLabel: string
  icon?: React.ComponentType<{ className?: string }>
}

interface OnboardingStepFailure {
  id: string
  message: string
}

const STEP_FAILED = "That step did not finish. Try it again."

/** Ids that no longer match a step are not progress, so the count can never claim more than is really done. */
function doneStepIds(steps: readonly OnboardingStep[], completed: readonly string[]) {
  return steps.filter((step) => completed.includes(step.id)).map((step) => step.id)
}

function firstOpenStep(steps: readonly OnboardingStep[], done: readonly string[]) {
  return steps.find((step) => !done.includes(step.id))
}

interface OnboardingOptions {
  steps: readonly OnboardingStep[]
  completed?: readonly string[]
  defaultCompleted: readonly string[]
  onCompletedChange?: (completed: readonly string[]) => void
  onStep?: (id: string) => void | Promise<void>
}

function useOnboarding({ steps, completed, defaultCompleted, onCompletedChange, onStep }: OnboardingOptions) {
  const [internal, setInternal] = useState(defaultCompleted)
  const [pendingId, setPendingId] = useState<string | null>(null)
  const [failure, setFailure] = useState<OnboardingStepFailure | null>(null)
  const current = completed ?? internal
  const done = doneStepIds(steps, current)

  const setDone = (id: string, state: boolean) => {
    const without = current.filter((item) => item !== id)
    const next = state ? [...without, id] : without
    setInternal(next)
    onCompletedChange?.(next)
  }

  /** True once the step's work went through, so a screen can move on. A controlled owner still decides what counts as done. */
  const press = async (id: string) => {
    if (pendingId !== null) return false
    setFailure(null)
    setPendingId(id)
    try {
      await onStep?.(id)
      if (completed === undefined) setDone(id, true)
      return true
    } catch (problem) {
      setFailure({ id, message: problem instanceof Error ? problem.message : STEP_FAILED })
      return false
    } finally {
      setPendingId(null)
    }
  }

  return {
    done,
    doneCount: done.length,
    total: steps.length,
    next: firstOpenStep(steps, done),
    allDone: done.length === steps.length,
    pendingId,
    failure,
    setDone,
    press,
  }
}

type Onboarding = ReturnType<typeof useOnboarding>

export { doneStepIds, firstOpenStep, useOnboarding, type Onboarding, type OnboardingOptions, type OnboardingStep }
```
