# Multi-Step Setup Wizard

> Onboarding wizard card stepping through profile, workspace, invites, and a summary, with a progress bar, step indicator, and preference switches.

A shadcn/ui block from the [@7ovr](https://7ovr.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/7ovr/onboarding-3).

- Page, with a live preview: https://shadcn.exchange/r/7ovr/onboarding-3
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/7ovr/onboarding-3-light-0a5f52ae803ccf10.png
- Registry item: https://7ovr.com/r/onboarding-3.json
- Categories: onboarding
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://7ovr.com/r/onboarding-3.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: `@base-ui/react`
- Registry items: `badge`, `button`, `card`, `field`, `input`, `label`, `progress`, `separator`, `sonner`, `switch`

## Files

### `registry/blocks/onboarding/3/onboarding-block.tsx`

```tsx
"use client"

import { useState } from "react"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { Toaster } from "@/components/ui/sonner"
import { IconPlaceholder } from "@/components/icons/icon-placeholder"

type IconProps = { className?: string; size?: number | string }

const steps = [
  {
    id: 1,
    title: "Profile",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="User"
        tabler="IconUser"
        hugeicons="UserIcon"
        phosphor="User"
        remixicon="RiUserLine"
        {...p}
      />
    ),
  },
  {
    id: 2,
    title: "Workspace",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="Building2"
        tabler="IconBuilding"
        hugeicons="BuildingIcon"
        phosphor="Building"
        remixicon="RiBuilding2Line"
        {...p}
      />
    ),
  },
  {
    id: 3,
    title: "Invite",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="Users"
        tabler="IconUsersGroup"
        hugeicons="UserGroupIcon"
        phosphor="UsersThree"
        remixicon="RiTeamLine"
        {...p}
      />
    ),
  },
  {
    id: 4,
    title: "Done",
    icon: (p: IconProps) => (
      <IconPlaceholder
        lucide="Check"
        tabler="IconCheck"
        hugeicons="Tick02Icon"
        phosphor="Check"
        remixicon="RiCheckLine"
        {...p}
      />
    ),
  },
] as const

type Prefs = {
  notifications: boolean
  weeklyDigest: boolean
  publicProfile: boolean
}

export default function OnboardingBlock() {
  const [step, setStep] = useState(1)
  const [firstName, setFirstName] = useState("Jordan")
  const [lastName, setLastName] = useState("Rivera")
  const [role, setRole] = useState("Product Designer")
  const [workspace, setWorkspace] = useState("Acme HQ")
  const [prefs, setPrefs] = useState<Prefs>({
    notifications: true,
    weeklyDigest: true,
    publicProfile: false,
  })
  const [invites, setInvites] = useState<string[]>([
    "casey@acme.com",
    "morgan@acme.com",
  ])
  const [inviteDraft, setInviteDraft] = useState("")
  const [finished, setFinished] = useState(false)

  const total = steps.length
  const isLast = step === total
  const progress = (step / total) * 100

  function next() {
    setStep((s) => Math.min(s + 1, total))
  }

  function back() {
    setStep((s) => Math.max(s - 1, 1))
  }

  function finish() {
    setFinished(true)
    toast.success("Welcome To Acme", {
      description: `Your workspace "${workspace}" is ready. ${invites.length} teammate${
        invites.length === 1 ? "" : "s"
      } invited.`,
    })
  }

  function addInvite() {
    const value = inviteDraft.trim()
    if (!value || invites.includes(value)) return
    setInvites((list) => [...list, value])
    setInviteDraft("")
  }

  function removeInvite(email: string) {
    setInvites((list) => list.filter((e) => e !== email))
  }

  function togglePref(key: keyof Prefs) {
    setPrefs((p) => ({ ...p, [key]: !p[key] }))
  }

  return (
    <section className="flex w-full items-center justify-center bg-muted/30 px-6 py-16 text-foreground">
      <Card className="mx-auto w-full max-w-2xl">
        <CardHeader className="gap-6">
          <div className="flex items-center justify-between gap-4">
            <div className="flex items-center gap-2.5">
              <svg
                viewBox="0 0 24 24"
                fill="currentColor"
                aria-hidden="true"
                className="size-6 shrink-0 text-primary"
              >
                <rect
                  x="3"
                  y="3"
                  width="8"
                  height="8"
                  transform="rotate(-6 7 7)"
                />
                <rect
                  x="3"
                  y="13"
                  width="8"
                  height="8"
                  transform="rotate(5 7 17)"
                />
                <rect
                  x="13"
                  y="13"
                  width="8"
                  height="8"
                  transform="rotate(-4 17 17)"
                />
                <rect
                  x="13"
                  y="3"
                  width="8"
                  height="8"
                  transform="rotate(15 17 7)"
                />
              </svg>
              <span className="text-sm font-semibold tracking-tight">Acme</span>
            </div>
            <Badge variant="secondary">
              Step {step} of {total}
            </Badge>
          </div>

          <div className="space-y-3">
            <Progress
              value={progress}
              aria-label={`Step ${step} of ${total}`}
            />
            <ol className="flex items-center justify-between">
              {steps.map((s) => {
                const active = s.id === step
                const done = s.id < step
                const Icon = s.icon
                return (
                  <li key={s.id} className="flex flex-1 justify-center">
                    <button
                      type="button"
                      onClick={() => setStep(s.id)}
                      aria-current={active ? "step" : undefined}
                      className="group flex cursor-pointer flex-col items-center gap-1.5 text-center transition-opacity hover:opacity-80 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
                    >
                      <span
                        className={cn(
                          "flex size-8 items-center justify-center rounded-full border text-xs font-medium transition-colors",
                          active &&
                            "border-primary bg-primary text-primary-foreground",
                          done &&
                            "border-primary/40 bg-primary/10 text-primary",
                          !active &&
                            !done &&
                            "border-border bg-background text-muted-foreground group-hover:border-primary/40 group-hover:text-foreground"
                        )}
                      >
                        {done ? (
                          <IconPlaceholder
                            lucide="Check"
                            tabler="IconCheck"
                            hugeicons="Tick02Icon"
                            phosphor="Check"
                            remixicon="RiCheckLine"
                            className="size-4"
                            aria-hidden="true"
                          />
                        ) : (
                          <Icon className="size-4" aria-hidden="true" />
                        )}
                      </span>
                      <span
                        className={cn(
                          "text-xs font-medium",
                          active ? "text-foreground" : "text-muted-foreground"
                        )}
                      >
                        {s.title}
                      </span>
                    </button>
                  </li>
                )
              })}
            </ol>
          </div>
        </CardHeader>

        <CardContent className="min-h-64">
          {step === 1 && (
            <div className="space-y-5">
              <div className="space-y-1">
                <h2 className="font-heading text-lg font-semibold tracking-tight">
                  Tell us about you
                </h2>
                <p className="text-sm text-muted-foreground">
                  This is how teammates will see you across Acme.
                </p>
              </div>
              <div className="grid gap-4 sm:grid-cols-2">
                <Field>
                  <FieldLabel htmlFor="first-name">First name</FieldLabel>
                  <Input
                    id="first-name"
                    value={firstName}
                    onChange={(e) => setFirstName(e.target.value)}
                  />
                </Field>
                <Field>
                  <FieldLabel htmlFor="last-name">Last name</FieldLabel>
                  <Input
                    id="last-name"
                    value={lastName}
                    onChange={(e) => setLastName(e.target.value)}
                  />
                </Field>
              </div>
              <Field>
                <FieldLabel htmlFor="role">Role</FieldLabel>
                <Input
                  id="role"
                  value={role}
                  onChange={(e) => setRole(e.target.value)}
                />
              </Field>
            </div>
          )}

          {step === 2 && (
            <div className="space-y-5">
              <div className="space-y-1">
                <h2 className="font-heading text-lg font-semibold tracking-tight">
                  Workspace preferences
                </h2>
                <p className="text-sm text-muted-foreground">
                  Set up how your workspace behaves. Change these any time.
                </p>
              </div>
              <Field>
                <FieldLabel htmlFor="workspace">Workspace name</FieldLabel>
                <Input
                  id="workspace"
                  value={workspace}
                  onChange={(e) => setWorkspace(e.target.value)}
                />
              </Field>
              <div className="divide-y divide-border rounded-lg border border-border">
                <PrefRow
                  id="pref-notifications"
                  label="Product notifications"
                  description="Get notified about activity in your workspace."
                  checked={prefs.notifications}
                  onChange={() => togglePref("notifications")}
                />
                <PrefRow
                  id="pref-digest"
                  label="Weekly digest"
                  description="A summary of what shipped, every Monday."
                  checked={prefs.weeklyDigest}
                  onChange={() => togglePref("weeklyDigest")}
                />
                <PrefRow
                  id="pref-public"
                  label="Public profile"
                  description="Let others outside your team find you."
                  checked={prefs.publicProfile}
                  onChange={() => togglePref("publicProfile")}
                />
              </div>
            </div>
          )}

          {step === 3 && (
            <div className="space-y-5">
              <div className="space-y-1">
                <h2 className="font-heading text-lg font-semibold tracking-tight">
                  Invite your team
                </h2>
                <p className="text-sm text-muted-foreground">
                  Acme is better together. Add teammates by email.
                </p>
              </div>
              <Field>
                <FieldLabel htmlFor="invite">Email address</FieldLabel>
                <div className="flex gap-2">
                  <Input
                    id="invite"
                    type="email"
                    placeholder="name@acme.com"
                    value={inviteDraft}
                    onChange={(e) => setInviteDraft(e.target.value)}
                    onKeyDown={(e) => {
                      if (e.key === "Enter") {
                        e.preventDefault()
                        addInvite()
                      }
                    }}
                  />
                  <Button type="button" onClick={addInvite}>
                    Add
                  </Button>
                </div>
              </Field>
              <ul className="space-y-2">
                {invites.map((email) => (
                  <li
                    key={email}
                    className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card px-3 py-2"
                  >
                    <span className="flex min-w-0 items-center gap-2 text-sm">
                      <IconPlaceholder
                        lucide="Mail"
                        tabler="IconMail"
                        hugeicons="MailIcon"
                        phosphor="Envelope"
                        remixicon="RiMailLine"
                        className="size-4 shrink-0 text-muted-foreground"
                        aria-hidden="true"
                      />
                      <span className="truncate">{email}</span>
                    </span>
                    <button
                      type="button"
                      onClick={() => removeInvite(email)}
                      className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
                    >
                      Remove
                    </button>
                  </li>
                ))}
                {invites.length === 0 && (
                  <li className="rounded-lg border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
                    No invites yet. Add a teammate above.
                  </li>
                )}
              </ul>
            </div>
          )}

          {step === 4 && (
            <div className="space-y-5">
              <div className="flex flex-col items-center gap-3 text-center">
                <span className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
                  <IconPlaceholder
                    lucide="Check"
                    tabler="IconCheck"
                    hugeicons="Tick02Icon"
                    phosphor="Check"
                    remixicon="RiCheckLine"
                    className="size-6"
                    aria-hidden="true"
                  />
                </span>
                <div className="space-y-1">
                  <h2 className="font-heading text-lg font-semibold tracking-tight">
                    You&apos;re all set
                  </h2>
                  <p className="text-sm text-muted-foreground">
                    Review your setup, then jump into your workspace.
                  </p>
                </div>
              </div>
              <div className="rounded-lg border border-border bg-muted/40 p-4">
                <dl className="space-y-3 text-sm">
                  <SummaryRow
                    label="Name"
                    value={`${firstName} ${lastName}`.trim() || "Not set"}
                  />
                  <Separator />
                  <SummaryRow label="Role" value={role || "Not set"} />
                  <Separator />
                  <SummaryRow
                    label="Workspace"
                    value={workspace || "Not set"}
                  />
                  <Separator />
                  <SummaryRow
                    label="Invites"
                    value={`${invites.length} teammate${
                      invites.length === 1 ? "" : "s"
                    }`}
                  />
                </dl>
              </div>
            </div>
          )}
        </CardContent>

        <CardFooter className="justify-between gap-3">
          <Button
            type="button"
            variant="outline"
            onClick={back}
            disabled={step === 1}
          >
            <IconPlaceholder
              lucide="ArrowLeft"
              tabler="IconArrowLeft"
              hugeicons="ArrowLeftIcon"
              phosphor="ArrowLeft"
              remixicon="RiArrowLeftLine"
              data-icon="inline-start"
              className="size-4"
              aria-hidden="true"
            />
            Back
          </Button>
          {isLast ? (
            <Button type="button" onClick={finish} disabled={finished}>
              {finished ? "All Set" : "Finish"}
              <IconPlaceholder
                lucide="Check"
                tabler="IconCheck"
                hugeicons="Tick02Icon"
                phosphor="Check"
                remixicon="RiCheckLine"
                className="size-4"
                aria-hidden="true"
              />
            </Button>
          ) : (
            <Button type="button" onClick={next}>
              Next
              <IconPlaceholder
                lucide="ArrowRight"
                tabler="IconArrowRight"
                hugeicons="ArrowRightIcon"
                phosphor="ArrowRight"
                remixicon="RiArrowRightLine"
                className="size-4"
                aria-hidden="true"
              />
            </Button>
          )}
        </CardFooter>
      </Card>
      <Toaster />
    </section>
  )
}

function PrefRow({
  id,
  label,
  description,
  checked,
  onChange,
}: {
  id: string
  label: string
  description: string
  checked: boolean
  onChange: () => void
}) {
  return (
    <div className="flex items-start justify-between gap-4 px-4 py-3">
      <div className="space-y-0.5">
        <Label htmlFor={id} className="text-sm font-medium">
          {label}
        </Label>
        <p className="text-xs text-muted-foreground">{description}</p>
      </div>
      <Switch id={id} checked={checked} onCheckedChange={onChange} />
    </div>
  )
}

function SummaryRow({ label, value }: { label: string; value: string }) {
  return (
    <div className="flex items-center justify-between gap-4">
      <dt className="text-muted-foreground">{label}</dt>
      <dd className="font-medium">{value}</dd>
    </div>
  )
}
```
