{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "onboarding-1",
  "title": "Onboarding 1: workspace setup wizard",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "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.",
  "dependencies": [
    "lucide-react",
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/field.json",
    "https://ui.ballmac.com/r/input.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/onboarding-1/onboarding-1.tsx",
      "content": "// Ballmac UI: Onboarding 1. https://ui.ballmac.com/blocks/onboarding-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowLeft, ArrowRight, Check, Plus, Rocket, X } from \"lucide-react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\n\nimport { Button, buttonVariants } from \"@/components/ballmac/button\"\nimport { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, useFieldControl } from \"@/components/ballmac/field\"\nimport { Input } from \"@/components/ballmac/input\"\nimport { cn } from \"@/lib/utils\"\n\ntype Onboarding1Goal = { id: string; title: string; description: string; icon?: React.ReactNode }\n\ntype Onboarding1Values = { name: string; slug: string; color: string; goals: string[]; invites: string[] }\n\ntype Onboarding1Props = Omit<React.ComponentProps<\"section\">, \"onSubmit\"> & {\n  /** Product name used in the copy and the web address prefix. */\n  product?: string\n  /** Domain shown before the workspace address. */\n  domain?: string\n  /** What people can pick on the second step. */\n  goals?: Onboarding1Goal[]\n  /** Called with the answers when the last step is confirmed. Throw to show an error; resolve to show the finish screen. */\n  onComplete?: (values: Onboarding1Values) => void | Promise<void>\n  /** Where the final button goes. */\n  continueHref?: string\n}\n\nconst colors = [\n  { id: \"chart-1\", label: \"Blue\", cls: \"bg-chart-1\" },\n  { id: \"chart-3\", label: \"Amber\", cls: \"bg-chart-3\" },\n  { id: \"chart-5\", label: \"Violet\", cls: \"bg-chart-5\" },\n  { id: \"chart-2\", label: \"Green\", cls: \"bg-chart-2\" },\n  { id: \"chart-4\", label: \"Coral\", cls: \"bg-chart-4\" },\n]\n\nconst defaultGoals: Onboarding1Goal[] = [\n  { id: \"invoicing\", title: \"Send invoices\", description: \"Bill customers and get paid faster\" },\n  { id: \"expenses\", title: \"Track expenses\", description: \"Receipts, cards and approvals\" },\n  { id: \"reporting\", title: \"Build reports\", description: \"Revenue, cash flow and trends\" },\n  { id: \"payroll\", title: \"Run payroll\", description: \"Pay your team on time, every time\" },\n]\n\nconst stepNames = [\"Workspace\", \"Your goals\", \"Your team\", \"All set\"]\nconst emailPattern = /^\\S+@\\S+\\.\\S+$/\nconst slugify = (s: string) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, \"-\").replace(/^-+|-+$/g, \"\")\n\nfunction FieldInput(props: React.ComponentProps<typeof Input>) {\n  return <Input {...useFieldControl()} {...props} />\n}\n\nfunction Onboarding1({\n  product = \"Acme\",\n  domain = \"app.acme.com\",\n  goals = defaultGoals,\n  onComplete,\n  continueHref = \"#\",\n  className,\n  ...props\n}: Onboarding1Props) {\n  const reduce = useReducedMotion()\n  const [step, setStep] = React.useState(0)\n  const [dir, setDir] = React.useState(1)\n  const [name, setName] = React.useState(\"\")\n  const [slug, setSlug] = React.useState(\"\")\n  const [slugEdited, setSlugEdited] = React.useState(false)\n  const [color, setColor] = React.useState(\"chart-1\")\n  const [picked, setPicked] = React.useState<string[]>([])\n  const [invites, setInvites] = React.useState<string[]>([])\n  const [draft, setDraft] = React.useState(\"\")\n  const [draftError, setDraftError] = React.useState<string>()\n  const [tried, setTried] = React.useState(false)\n  const [busy, setBusy] = React.useState(false)\n  const [failed, setFailed] = React.useState(false)\n  // The heading mounts only after the previous step has animated out, so focus it as it mounts (not on first load).\n  const ready = React.useRef(false)\n  React.useEffect(() => {\n    ready.current = true\n  }, [])\n  const headingRef = React.useCallback((node: HTMLHeadingElement | null) => {\n    if (node && ready.current) node.focus()\n  }, [])\n\n  const nameError = tried && !name.trim() ? \"Give your workspace a name.\" : undefined\n  const slugError = tried && !slug ? \"Choose a web address.\" : undefined\n  const goalError = tried && picked.length === 0 ? \"Choose at least one.\" : undefined\n\n  function go(next: number) {\n    setDir(next > step ? 1 : -1)\n    setTried(false)\n    setStep(next)\n  }\n\n  function addInvite() {\n    const value = draft.trim().replace(/,$/, \"\")\n    if (!value) return true\n    if (!emailPattern.test(value)) {\n      setDraftError(\"Enter a full email address.\")\n      return false\n    }\n    if (invites.includes(value)) {\n      setDraftError(\"Already on the list.\")\n      return false\n    }\n    setInvites((i) => [...i, value])\n    setDraft(\"\")\n    setDraftError(undefined)\n    return true\n  }\n\n  async function next() {\n    if (step === 0) {\n      setTried(true)\n      if (!name.trim() || !slug) return\n      go(1)\n    } else if (step === 1) {\n      setTried(true)\n      if (picked.length === 0) return\n      go(2)\n    } else if (step === 2) {\n      if (!addInvite()) return\n      go(3)\n    } else if (step === 3) {\n      setBusy(true)\n      setFailed(false)\n      try {\n        const values = { name: name.trim(), slug, color, goals: picked, invites }\n        if (onComplete) await onComplete(values)\n        else await new Promise((r) => setTimeout(r, 900))\n        setStep(4)\n        setDir(1)\n      } catch {\n        setFailed(true)\n      } finally {\n        setBusy(false)\n      }\n    }\n  }\n\n  const done = step === 4\n  const slide = reduce ? {} : { initial: { opacity: 0, x: dir * 28 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: dir * -28 } }\n  const swatch = colors.find((c) => c.id === color) ?? colors[0]\n  const heading = \"text-2xl font-semibold tracking-[-0.03em] outline-none sm:text-3xl\"\n\n  return (\n    <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}>\n      <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]\">\n        {/* Progress: a rail on tablets and up, a compact bar on phones. */}\n        <div className=\"bg-muted/40 border-b p-5 md:border-e md:border-b-0 md:p-8\">\n          <p className=\"text-sm font-semibold tracking-tight\">{product}</p>\n          <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>\n          <div className=\"bg-muted mt-2 h-1.5 overflow-hidden rounded-full md:hidden\" aria-hidden=\"true\">\n            <div className=\"bg-foreground h-full rounded-full transition-[width] duration-500 motion-reduce:transition-none\" style={{ width: `${((done ? 4 : step + 1) / 4) * 100}%` }} />\n          </div>\n          <ol aria-label=\"Setup progress\" className=\"mt-8 hidden space-y-1 md:block\">\n            {stepNames.map((n, i) => {\n              const complete = done || i < step\n              const current = !done && i === step\n              return (\n                <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\")}>\n                  <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\")}>\n                    {complete ? <Check className=\"size-3.5\" aria-hidden=\"true\" /> : i + 1}\n                  </span>\n                  {n}\n                  {complete && <span className=\"sr-only\"> (done)</span>}\n                </li>\n              )\n            })}\n          </ol>\n        </div>\n\n        <div className=\"flex min-h-[32rem] min-w-0 flex-col p-6 sm:p-10\">\n          <div className=\"flex-1\">\n            <AnimatePresence mode=\"wait\" initial={false}>\n              <motion.div key={step} {...slide} transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}>\n                {step === 0 && (\n                  <>\n                    <h1 ref={headingRef} tabIndex={-1} className={heading}>Name your workspace</h1>\n                    <p className=\"text-muted-foreground mt-2 text-pretty\">This is where your team’s work lives. You can change it later.</p>\n                    <FieldGroup className=\"mt-8\">\n                      <Field invalid={!!nameError}>\n                        <FieldLabel required>Workspace name</FieldLabel>\n                        <FieldInput\n                          autoComplete=\"organization\"\n                          placeholder=\"Northwind Studio\"\n                          value={name}\n                          onChange={(e) => { setName(e.target.value); if (!slugEdited) setSlug(slugify(e.target.value)) }}\n                        />\n                        <FieldError errors={[nameError]} />\n                      </Field>\n                      <Field invalid={!!slugError}>\n                        <FieldLabel required>Web address</FieldLabel>\n                        <div className=\"flex\">\n                          <span className=\"bg-muted text-muted-foreground flex items-center rounded-s-md border border-e-0 px-3 text-sm\">{domain}/</span>\n                          <FieldInput className=\"rounded-s-none\" autoCapitalize=\"none\" spellCheck={false} placeholder=\"northwind\" value={slug} onChange={(e) => { setSlug(slugify(e.target.value)); setSlugEdited(true) }} />\n                        </div>\n                        <FieldError errors={[slugError]} />\n                      </Field>\n                      <fieldset>\n                        <legend className=\"mb-2 text-sm font-medium\">Workspace color</legend>\n                        <div className=\"flex gap-2.5\">\n                          {colors.map((c) => (\n                            <label key={c.id} className=\"relative cursor-pointer\">\n                              <input type=\"radio\" name=\"onboarding-color\" value={c.id} checked={color === c.id} onChange={() => setColor(c.id)} className=\"peer sr-only\" />\n                              <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)}>\n                                {color === c.id && <Check className=\"text-background size-4\" aria-hidden=\"true\" />}\n                              </span>\n                              <span className=\"sr-only\">{c.label}</span>\n                            </label>\n                          ))}\n                        </div>\n                      </fieldset>\n                    </FieldGroup>\n                  </>\n                )}\n\n                {step === 1 && (\n                  <>\n                    <h1 ref={headingRef} tabIndex={-1} className={heading}>What will you use {product} for?</h1>\n                    <p className=\"text-muted-foreground mt-2 text-pretty\">Pick everything that applies. We’ll set up the right things first.</p>\n                    <fieldset className=\"mt-8\">\n                      <legend className=\"sr-only\">Your goals</legend>\n                      <div className=\"grid gap-3 sm:grid-cols-2\">\n                        {goals.map((g) => {\n                          const on = picked.includes(g.id)\n                          return (\n                            <label key={g.id} className=\"relative cursor-pointer\">\n                              <input type=\"checkbox\" checked={on} onChange={() => setPicked((p) => (on ? p.filter((x) => x !== g.id) : [...p, g.id]))} className=\"peer sr-only\" />\n                              <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]\">\n                                <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\")}>\n                                  {on && <Check className=\"size-3.5\" aria-hidden=\"true\" />}\n                                </span>\n                                <span>\n                                  <span className=\"block text-sm font-medium\">{g.title}</span>\n                                  <span className=\"text-muted-foreground block text-sm\">{g.description}</span>\n                                </span>\n                              </span>\n                            </label>\n                          )\n                        })}\n                      </div>\n                      {goalError && <p role=\"alert\" className=\"text-destructive mt-3 text-sm\">{goalError}</p>}\n                    </fieldset>\n                  </>\n                )}\n\n                {step === 2 && (\n                  <>\n                    <h1 ref={headingRef} tabIndex={-1} className={heading}>Invite your team</h1>\n                    <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>\n                    <FieldGroup className=\"mt-8\">\n                      <Field invalid={!!draftError}>\n                        <FieldLabel>Email addresses</FieldLabel>\n                        <div className=\"flex gap-2\">\n                          <FieldInput\n                            type=\"email\"\n                            placeholder=\"teammate@company.com\"\n                            value={draft}\n                            onChange={(e) => { setDraft(e.target.value); setDraftError(undefined) }}\n                            onKeyDown={(e) => { if (e.key === \"Enter\" || e.key === \",\") { e.preventDefault(); addInvite() } }}\n                          />\n                          <Button type=\"button\" variant=\"outline\" onClick={addInvite}><Plus /> Add</Button>\n                        </div>\n                        <FieldError errors={[draftError]} />\n                        {!draftError && <FieldDescription>Press Enter to add each address.</FieldDescription>}\n                      </Field>\n                    </FieldGroup>\n                    {invites.length > 0 && (\n                      <ul aria-label=\"People to invite\" className=\"mt-5 flex flex-wrap gap-2\">\n                        {invites.map((e) => (\n                          <li key={e} className=\"bg-secondary flex items-center gap-1 rounded-full py-1 pe-1 ps-3 text-sm\">\n                            {e}\n                            <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]\">\n                              <X className=\"size-3.5\" aria-hidden=\"true\" />\n                            </button>\n                          </li>\n                        ))}\n                      </ul>\n                    )}\n                  </>\n                )}\n\n                {step === 3 && (\n                  <>\n                    <h1 ref={headingRef} tabIndex={-1} className={heading}>Ready to go?</h1>\n                    <p className=\"text-muted-foreground mt-2 text-pretty\">Here’s what we’ll create. You can change any of it later.</p>\n                    <dl className=\"mt-8 divide-y rounded-2xl border\">\n                      <div className=\"flex items-center justify-between gap-4 p-4\">\n                        <dt className=\"text-muted-foreground text-sm\">Workspace</dt>\n                        <dd className=\"flex items-center gap-2.5 text-sm font-medium\">\n                          <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>\n                          {name.trim()}\n                        </dd>\n                      </div>\n                      <div className=\"flex items-center justify-between gap-4 p-4\">\n                        <dt className=\"text-muted-foreground text-sm\">Address</dt>\n                        <dd className=\"min-w-0 truncate font-mono text-sm\">{domain}/{slug}</dd>\n                      </div>\n                      <div className=\"flex items-center justify-between gap-4 p-4\">\n                        <dt className=\"text-muted-foreground text-sm\">Goals</dt>\n                        <dd className=\"text-end text-sm font-medium\">{picked.map((id) => goals.find((g) => g.id === id)?.title).join(\", \")}</dd>\n                      </div>\n                      <div className=\"flex items-center justify-between gap-4 p-4\">\n                        <dt className=\"text-muted-foreground text-sm\">Invitations</dt>\n                        <dd className=\"text-sm font-medium\">{invites.length ? `${invites.length} ${invites.length === 1 ? \"person\" : \"people\"}` : \"None yet\"}</dd>\n                      </div>\n                    </dl>\n                    {failed && <p role=\"alert\" className=\"text-destructive mt-4 text-sm\">We couldn’t create the workspace. Please try again.</p>}\n                  </>\n                )}\n\n                {done && (\n                  <div className=\"flex min-h-[24rem] flex-col items-center justify-center text-center\">\n                    <span aria-hidden=\"true\" className={cn(\"text-background flex size-16 items-center justify-center rounded-2xl\", swatch.cls)}><Rocket className=\"size-8\" /></span>\n                    <h1 ref={headingRef} tabIndex={-1} className={cn(heading, \"mt-6\")}>{name.trim()} is ready</h1>\n                    <p role=\"status\" className=\"text-muted-foreground mt-2 max-w-sm text-pretty\">\n                      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.\n                    </p>\n                    <a href={continueHref} className={buttonVariants({ size: \"lg\", shape: \"pill\", className: \"mt-8\" })}>\n                      Open your workspace <ArrowRight  className=\"rtl:rotate-180\"/>\n                    </a>\n                  </div>\n                )}\n              </motion.div>\n            </AnimatePresence>\n          </div>\n\n          {!done && (\n            <div className=\"mt-10 flex items-center justify-between gap-3\">\n              {step > 0 ? <Button variant=\"ghost\" onClick={() => go(step - 1)}><ArrowLeft  className=\"rtl:rotate-180\"/> Back</Button> : <span />}\n              <div className=\"flex items-center gap-2\">\n                {step === 2 && <Button variant=\"ghost\" onClick={() => go(3)}>Skip for now</Button>}\n                <Button shape=\"pill\" size=\"lg\" loading={busy} onClick={next}>\n                  {step === 3 ? \"Create workspace\" : \"Continue\"} {step < 3 && <ArrowRight  className=\"rtl:rotate-180\"/>}\n                </Button>\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n    </section>\n  )\n}\n\nexport { Onboarding1, type Onboarding1Props, type Onboarding1Values, type Onboarding1Goal }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/onboarding-1/onboarding-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "onboarding",
      "wizard",
      "setup",
      "workspace",
      "steps",
      "invite"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "First-run setup after signup. Pass product, domain, goals=[{ id, title, description }] and onComplete(values) with { name, slug, color, goals, invites } (throw to show an error).",
      "whenToUse": [
        "The first screen after signup or verification",
        "Products where a workspace or team has to be created"
      ],
      "whenNotToUse": [
        "A checklist that lives inside the app (use onboarding-checklist)"
      ],
      "composesWith": [
        "signup-1",
        "verify-1",
        "invite-1",
        "app-shell"
      ],
      "a11y": [
        {
          "keys": "Continue / Back",
          "action": "Focus moves to the new step's heading so the change is announced"
        },
        {
          "keys": "Arrow keys on colors",
          "action": "Move between color radios; goals are checkboxes toggled with Space"
        },
        {
          "keys": "Enter in the email field",
          "action": "Adds the address as a removable chip"
        }
      ],
      "customization": [
        "goals: { id, title, description }[]",
        "product, domain, continueHref",
        "onComplete(values) async"
      ]
    },
    "examples": [
      "onboarding-1-demo",
      "onboarding-1-custom"
    ],
    "url": "https://ui.ballmac.com/components/onboarding-1"
  },
  "categories": [
    "blocks",
    "onboarding"
  ],
  "type": "registry:block"
}