# Onboarding 1: workspace setup wizard

> A four-step setup wizard with a progress rail: name and address a workspace (auto-filled), pick goals as cards, invite teammates as removable chips, review, then a finish screen. Slides between steps.

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

- Page, with a live preview: https://shadcn.exchange/r/ballmac/onboarding-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/onboarding-1-light-b78a539e0f46882d.png
- Registry item: https://ui.ballmac.com/r/onboarding-1.json
- Categories: blocks, onboarding
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://ui.ballmac.com/r/onboarding-1.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`, `motion`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/field.json`, `https://ui.ballmac.com/r/input.json`, `https://ui.ballmac.com/r/use-resource.json`

## Files

### `registry/ballmac/components/blocks/onboarding-1/onboarding-1.tsx`

```tsx
// Ballmac UI: Onboarding 1. https://ui.ballmac.com/blocks/onboarding-1
"use client"

import * as React from "react"
import { ArrowLeft, ArrowRight, Check, Plus, Rocket, X } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { Button, buttonVariants } from "@/components/ballmac/button"
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, useFieldControl } from "@/components/ballmac/field"
import { Input } from "@/components/ballmac/input"
import { formPhase, type FormStateProps } from "@/lib/ballmac/use-resource"
import { cn } from "@/lib/utils"

type Onboarding1Goal = { id: string; title: string; description: string; icon?: React.ReactNode }

type Onboarding1Values = { name: string; slug: string; color: string; goals: string[]; invites: string[] }

type Onboarding1Props = Omit<React.ComponentProps<"section">, "onSubmit"> & FormStateProps & {
  /** Product name used in the copy and the web address prefix. */
  product?: string
  /** Domain shown before the workspace address. */
  domain?: string
  /** What people can pick on the second step. */
  goals?: Onboarding1Goal[]
  /** Called with the answers when the last step is confirmed. Throw to show an error; resolve to show the finish screen. */
  onComplete?: (values: Onboarding1Values) => void | Promise<void>
  /** Where the final button goes. */
  continueHref?: string
}

const colors = [
  { id: "chart-1", label: "Blue", cls: "bg-chart-1" },
  { id: "chart-3", label: "Amber", cls: "bg-chart-3" },
  { id: "chart-5", label: "Violet", cls: "bg-chart-5" },
  { id: "chart-2", label: "Green", cls: "bg-chart-2" },
  { id: "chart-4", label: "Coral", cls: "bg-chart-4" },
]

const defaultGoals: Onboarding1Goal[] = [
  { id: "invoicing", title: "Send invoices", description: "Bill customers and get paid faster" },
  { id: "expenses", title: "Track expenses", description: "Receipts, cards and approvals" },
  { id: "reporting", title: "Build reports", description: "Revenue, cash flow and trends" },
  { id: "payroll", title: "Run payroll", description: "Pay your team on time, every time" },
]

const stepNames = ["Workspace", "Your goals", "Your team", "All set"]
const emailPattern = /^\S+@\S+\.\S+$/
const slugify = (s: string) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")

function FieldInput(props: React.ComponentProps<typeof Input>) {
  return <Input {...useFieldControl()} {...props} />
}

function Onboarding1({
  product = "Acme",
  domain = "app.acme.com",
  goals = defaultGoals,
  onComplete,
  continueHref = "#",
  state: stateProp,
  errorState,
  className,
  ...props
}: Onboarding1Props) {
  const reduce = useReducedMotion()
  const [stepInternal, setStep] = React.useState(0)
  const [dir, setDir] = React.useState(1)
  const [name, setName] = React.useState("")
  const [slug, setSlug] = React.useState("")
  const [slugEdited, setSlugEdited] = React.useState(false)
  const [color, setColor] = React.useState("chart-1")
  const [picked, setPicked] = React.useState<string[]>([])
  const [invites, setInvites] = React.useState<string[]>([])
  const [draft, setDraft] = React.useState("")
  const [draftError, setDraftError] = React.useState<string>()
  const [tried, setTried] = React.useState(false)
  const [busyInternal, setBusy] = React.useState(false)
  const [failedInternal, setFailed] = React.useState(false)
  // a `state` prop drives the last step: submitting and error show the review step, success the finish screen; without it the wizard runs by itself
  const phase = formPhase(stateProp)
  const step = phase === "done" ? 4 : phase === "sending" || phase === "error" ? 3 : stepInternal
  const busy = phase === "sending" || busyInternal
  const failed = phase === "error" || (phase === undefined && failedInternal)
  // what the review and finish screens call the workspace when nothing was typed, as when a `state` shows them on their own
  const shownName = name.trim() || "Your workspace"
  // The heading mounts only after the previous step has animated out, so focus it as it mounts (not on first load).
  const ready = React.useRef(false)
  React.useEffect(() => {
    ready.current = true
  }, [])
  const headingRef = React.useCallback((node: HTMLHeadingElement | null) => {
    if (node && ready.current) node.focus()
  }, [])

  const nameError = tried && !name.trim() ? "Give your workspace a name." : undefined
  const slugError = tried && !slug ? "Choose a web address." : undefined
  const goalError = tried && picked.length === 0 ? "Choose at least one." : undefined

  function go(next: number) {
    setDir(next > step ? 1 : -1)
    setTried(false)
    setStep(next)
  }

  function addInvite() {
    const value = draft.trim().replace(/,$/, "")
    if (!value) return true
    if (!emailPattern.test(value)) {
      setDraftError("Enter a full email address.")
      return false
    }
    if (invites.includes(value)) {
      setDraftError("Already on the list.")
      return false
    }
    setInvites((i) => [...i, value])
    setDraft("")
    setDraftError(undefined)
    return true
  }

  async function next() {
    if (step === 0) {
      setTried(true)
      if (!name.trim() || !slug) return
      go(1)
    } else if (step === 1) {
      setTried(true)
      if (picked.length === 0) return
      go(2)
    } else if (step === 2) {
      if (!addInvite()) return
      go(3)
    } else if (step === 3) {
      setBusy(true)
      setFailed(false)
      try {
        const values = { name: name.trim(), slug, color, goals: picked, invites }
        if (onComplete) await onComplete(values)
        else await new Promise((r) => setTimeout(r, 900))
        setStep(4)
        setDir(1)
      } catch {
        setFailed(true)
      } finally {
        setBusy(false)
      }
    }
  }

  const done = step === 4
  const slide = reduce ? {} : { initial: { opacity: 0, x: dir * 28 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: dir * -28 } }
  const swatch = colors.find((c) => c.id === color) ?? colors[0]
  const heading = "text-2xl font-semibold tracking-[-0.03em] outline-none sm:text-3xl"

  return (
    <section data-slot="onboarding-1" className={cn("mx-auto flex min-h-[40rem] max-w-5xl items-center px-4 py-12 sm:px-6", className)} {...props}>
      <div className="bg-card grid w-full overflow-hidden rounded-3xl border shadow-[0_30px_80px_-50px_rgb(0_0_0/0.4)] md:grid-cols-[15rem_1fr]">
        {/* Progress: a rail on tablets and up, a compact bar on phones. */}
        <div className="bg-muted/40 border-b p-5 md:border-e md:border-b-0 md:p-8">
          <p className="text-sm font-semibold tracking-tight">{product}</p>
          <p className="text-muted-foreground mt-4 text-xs md:hidden">{done ? "Complete" : `Step ${step + 1} of ${stepNames.length}`}: {stepNames[Math.min(step, 3)]}</p>
          <div className="bg-muted mt-2 h-1.5 overflow-hidden rounded-full md:hidden" aria-hidden="true">
            <div className="bg-foreground h-full rounded-full transition-[width] duration-500 motion-reduce:transition-none" style={{ width: `${((done ? 4 : step + 1) / 4) * 100}%` }} />
          </div>
          <ol aria-label="Setup progress" className="mt-8 hidden space-y-1 md:block">
            {stepNames.map((n, i) => {
              const complete = done || i < step
              const current = !done && i === step
              return (
                <li key={n} aria-current={current ? "step" : undefined} className={cn("flex items-center gap-3 rounded-xl px-2 py-2.5 text-sm", current ? "bg-background font-medium shadow-xs ring-1 ring-border" : "text-muted-foreground")}>
                  <span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full border text-xs tabular-nums", complete && "bg-foreground text-background border-transparent", current && "border-foreground")}>
                    {complete ? <Check className="size-3.5" aria-hidden="true" /> : i + 1}
                  </span>
                  {n}
                  {complete && <span className="sr-only"> (done)</span>}
                </li>
              )
            })}
          </ol>
        </div>

        <div className="flex min-h-[32rem] min-w-0 flex-col p-6 sm:p-10">
          <div className="flex-1">
            <AnimatePresence mode="wait" initial={false}>
              <motion.div key={step} {...slide} transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}>
                {step === 0 && (
                  <>
                    <h1 ref={headingRef} tabIndex={-1} className={heading}>Name your workspace</h1>
                    <p className="text-muted-foreground mt-2 text-pretty">This is where your team’s work lives. You can change it later.</p>
                    <FieldGroup className="mt-8">
                      <Field invalid={!!nameError}>
                        <FieldLabel required>Workspace name</FieldLabel>
                        <FieldInput
                          autoComplete="organization"
                          placeholder="Northwind Studio"
                          value={name}
                          onChange={(e) => { setName(e.target.value); if (!slugEdited) setSlug(slugify(e.target.value)) }}
                        />
                        <FieldError errors={[nameError]} />
                      </Field>
                      <Field invalid={!!slugError}>
                        <FieldLabel required>Web address</FieldLabel>
                        <div className="flex">
                          <span className="bg-muted text-muted-foreground flex items-center rounded-s-md border border-e-0 px-3 text-sm">{domain}/</span>
                          <FieldInput className="rounded-s-none" autoCapitalize="none" spellCheck={false} placeholder="northwind" value={slug} onChange={(e) => { setSlug(slugify(e.target.value)); setSlugEdited(true) }} />
                        </div>
                        <FieldError errors={[slugError]} />
                      </Field>
                      <fieldset>
                        <legend className="mb-2 text-sm font-medium">Workspace color</legend>
                        <div className="flex gap-2.5">
                          {colors.map((c) => (
                            <label key={c.id} className="relative cursor-pointer">
                              <input type="radio" name="onboarding-color" value={c.id} checked={color === c.id} onChange={() => setColor(c.id)} className="peer sr-only" />
                              <span className={cn("peer-focus-visible:ring-ring/50 peer-checked:ring-foreground ring-offset-card flex size-9 items-center justify-center rounded-full ring-2 ring-transparent ring-offset-2 transition-shadow peer-focus-visible:ring-[3px]", c.cls)}>
                                {color === c.id && <Check className="text-background size-4" aria-hidden="true" />}
                              </span>
                              <span className="sr-only">{c.label}</span>
                            </label>
                          ))}
                        </div>
                      </fieldset>
                    </FieldGroup>
                  </>
                )}

                {step === 1 && (
                  <>
                    <h1 ref={headingRef} tabIndex={-1} className={heading}>What will you use {product} for?</h1>
                    <p className="text-muted-foreground mt-2 text-pretty">Pick everything that applies. We’ll set up the right things first.</p>
                    <fieldset className="mt-8">
                      <legend className="sr-only">Your goals</legend>
                      <div className="grid gap-3 sm:grid-cols-2">
                        {goals.map((g) => {
                          const on = picked.includes(g.id)
                          return (
                            <label key={g.id} className="relative cursor-pointer">
                              <input type="checkbox" checked={on} onChange={() => setPicked((p) => (on ? p.filter((x) => x !== g.id) : [...p, g.id]))} className="peer sr-only" />
                              <span className="peer-focus-visible:ring-ring/50 peer-checked:border-foreground peer-checked:bg-accent/60 hover:bg-accent/40 flex h-full items-start gap-3 rounded-2xl border p-4 transition-colors peer-focus-visible:ring-[3px]">
                                <span className={cn("mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-md border", on && "bg-foreground text-background border-transparent")}>
                                  {on && <Check className="size-3.5" aria-hidden="true" />}
                                </span>
                                <span>
                                  <span className="block text-sm font-medium">{g.title}</span>
                                  <span className="text-muted-foreground block text-sm">{g.description}</span>
                                </span>
                              </span>
                            </label>
                          )
                        })}
                      </div>
                      {goalError && <p role="alert" className="text-destructive mt-3 text-sm">{goalError}</p>}
                    </fieldset>
                  </>
                )}

                {step === 2 && (
                  <>
                    <h1 ref={headingRef} tabIndex={-1} className={heading}>Invite your team</h1>
                    <p className="text-muted-foreground mt-2 text-pretty">Add teammates by email. They’ll get an invitation to join {name.trim() || "your workspace"}. You can skip this.</p>
                    <FieldGroup className="mt-8">
                      <Field invalid={!!draftError}>
                        <FieldLabel>Email addresses</FieldLabel>
                        <div className="flex gap-2">
                          <FieldInput
                            type="email"
                            placeholder="teammate@company.com"
                            value={draft}
                            onChange={(e) => { setDraft(e.target.value); setDraftError(undefined) }}
                            onKeyDown={(e) => { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); addInvite() } }}
                          />
                          <Button type="button" variant="outline" onClick={addInvite}><Plus /> Add</Button>
                        </div>
                        <FieldError errors={[draftError]} />
                        {!draftError && <FieldDescription>Press Enter to add each address.</FieldDescription>}
                      </Field>
                    </FieldGroup>
                    {invites.length > 0 && (
                      <ul aria-label="People to invite" className="mt-5 flex flex-wrap gap-2">
                        {invites.map((e) => (
                          <li key={e} className="bg-secondary flex items-center gap-1 rounded-full py-1 pe-1 ps-3 text-sm">
                            {e}
                            <button type="button" aria-label={`Remove ${e}`} onClick={() => setInvites((i) => i.filter((x) => x !== e))} className="hover:bg-background focus-visible:ring-ring/50 flex size-6 items-center justify-center rounded-full outline-none focus-visible:ring-[3px]">
                              <X className="size-3.5" aria-hidden="true" />
                            </button>
                          </li>
                        ))}
                      </ul>
                    )}
                  </>
                )}

                {step === 3 && (
                  <>
                    <h1 ref={headingRef} tabIndex={-1} className={heading}>Ready to go?</h1>
                    <p className="text-muted-foreground mt-2 text-pretty">Here’s what we’ll create. You can change any of it later.</p>
                    <dl className="mt-8 divide-y rounded-2xl border">
                      <div className="flex items-center justify-between gap-4 p-4">
                        <dt className="text-muted-foreground text-sm">Workspace</dt>
                        <dd className="flex items-center gap-2.5 text-sm font-medium">
                          <span aria-hidden="true" className={cn("text-background flex size-6 items-center justify-center rounded-md text-xs font-bold", swatch.cls)}>{(name.trim()[0] ?? "W").toUpperCase()}</span>
                          {shownName}
                        </dd>
                      </div>
                      <div className="flex items-center justify-between gap-4 p-4">
                        <dt className="text-muted-foreground text-sm">Address</dt>
                        <dd className="min-w-0 truncate font-mono text-sm">{domain}/{slug || "your-workspace"}</dd>
                      </div>
                      <div className="flex items-center justify-between gap-4 p-4">
                        <dt className="text-muted-foreground text-sm">Goals</dt>
                        <dd className="text-end text-sm font-medium">{picked.length ? picked.map((id) => goals.find((g) => g.id === id)?.title).join(", ") : "None yet"}</dd>
                      </div>
                      <div className="flex items-center justify-between gap-4 p-4">
                        <dt className="text-muted-foreground text-sm">Invitations</dt>
                        <dd className="text-sm font-medium">{invites.length ? `${invites.length} ${invites.length === 1 ? "person" : "people"}` : "None yet"}</dd>
                      </div>
                    </dl>
                    {failed && <p role="alert" className="text-destructive mt-4 text-sm">{errorState?.description ?? "We couldn’t create the workspace. Please try again."}</p>}
                  </>
                )}

                {done && (
                  <div className="flex min-h-[24rem] flex-col items-center justify-center text-center">
                    <span aria-hidden="true" className={cn("text-background flex size-16 items-center justify-center rounded-2xl", swatch.cls)}><Rocket className="size-8" /></span>
                    <h1 ref={headingRef} tabIndex={-1} className={cn(heading, "mt-6")}>{shownName} is ready</h1>
                    <p role="status" className="text-muted-foreground mt-2 max-w-sm text-pretty">
                      Your workspace is set up{invites.length ? ` and ${invites.length} ${invites.length === 1 ? "invitation is" : "invitations are"} on the way` : ""}. Let’s get to work.
                    </p>
                    <a href={continueHref} className={buttonVariants({ size: "lg", shape: "pill", className: "mt-8" })}>
                      Open your workspace <ArrowRight  className="rtl:rotate-180"/>
                    </a>
                  </div>
                )}
              </motion.div>
            </AnimatePresence>
          </div>

          {!done && (
            <div className="mt-10 flex items-center justify-between gap-3">
              {step > 0 ? <Button variant="ghost" onClick={() => go(step - 1)}><ArrowLeft  className="rtl:rotate-180"/> Back</Button> : <span />}
              <div className="flex items-center gap-2">
                {step === 2 && <Button variant="ghost" onClick={() => go(3)}>Skip for now</Button>}
                <Button shape="pill" size="lg" loading={busy} onClick={next}>
                  {step === 3 ? "Create workspace" : "Continue"} {step < 3 && <ArrowRight  className="rtl:rotate-180"/>}
                </Button>
              </div>
            </div>
          )}
        </div>
      </div>
    </section>
  )
}

export { Onboarding1, type Onboarding1Props, type Onboarding1Values, type Onboarding1Goal }
```
