Stepper

Signup Wizard Bars

Bars version of Signup Wizard: email and password, profile, preference checkboxes and a welcome step, validated with react-hook-form and zod and tracked by plain rail segments.

Free, MIT licenseOpen in Browse
"use client"

import { useId, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"

import {
  Stepper,
  StepperContent,
  StepperItem,
  StepperList,
  StepperSeparator,
  StepperTitle,
} from "@/components/stepper/stepper"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

// The copy: change text here, not in the markup.
const COPY = {
  title: "Create your account",
  back: "Back",
  next: "Continue",
  finish: "Open workspace",
  steps: [
    { title: "Account", body: "Your email and a password." },
    { title: "Profile", body: "How your team sees you." },
    { title: "Preferences", body: "You can change these later." },
    { title: "Done", body: "Your workspace is ready." },
  ],
  email: "Email",
  emailError: "Enter a valid email address.",
  password: "Password",
  passwordHint: "At least twelve characters.",
  passwordError: "Use at least twelve characters.",
  name: "Display name",
  nameError: "Enter a display name.",
  role: "Role",
  prefs: [
    "Weekly digest by email",
    "Keyboard shortcuts on",
    "Dark mode follows the system",
  ],
  done: (name: string) =>
    `Welcome, ${name}. Orbcore Design is set up with you as its first admin.`,
  opened: "Opening your workspace. Hand this event to your router.",
} as const

const schema = z.object({
  email: z.email(COPY.emailError),
  password: z.string().min(12, COPY.passwordError),
  name: z.string().trim().min(1, COPY.nameError),
  role: z.string(),
  prefs: z.array(z.boolean()),
})
type Values = z.infer<typeof schema>

// The fields each step must pass before Continue; Enter submits the step.
const FIELDS: (keyof Values)[][] = [
  ["email", "password"],
  ["name", "role"],
  ["prefs"],
  [],
]

// The bars twin of signup-wizard: same panels, copy and validation; the rail is segments.
export default function SignupWizardBars() {
  const id = useId()
  const [step, setStep] = useState(0)
  const [opened, setOpened] = useState(false)
  const last = COPY.steps.length - 1
  const current = COPY.steps[step]
  const form = useForm<Values>({
    resolver: zodResolver(schema),
    mode: "onTouched",
    defaultValues: {
      email: "",
      password: "",
      name: "",
      role: "",
      prefs: [true, false, true],
    },
  })

  // Validate only the open step, then advance; the last step opens the workspace.
  const advance = async () => {
    if (!(await form.trigger(FIELDS[step]))) return
    if (step < last) setStep(step + 1)
    else setOpened(true)
  }

  return (
    <form
      className="mx-auto w-full max-w-md"
      onSubmit={(e) => {
        e.preventDefault()
        void advance()
      }}
    >
      <Stepper value={step} onValueChange={setStep} variant="bars">
        <StepperList>
          {COPY.steps.map((s, i) => (
            <StepperItem key={s.title} value={i}>
              <StepperSeparator />
              <StepperTitle className="sr-only">{s.title}</StepperTitle>
            </StepperItem>
          ))}
        </StepperList>
        <h2 className="text-xl font-semibold tracking-tight">
          {step === 0 ? COPY.title : current.title}
        </h2>
        <p className="-mt-4 text-sm text-muted-foreground">{current.body}</p>

        <StepperContent keepMounted value={0}>
          <FieldGroup>
            <Controller
              name="email"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor={`${id}su-email`}>
                    {COPY.email}
                  </FieldLabel>
                  <Input
                    {...field}
                    id={`${id}su-email`}
                    type="email"
                    autoComplete="email"
                    aria-invalid={fieldState.invalid}
                  />
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
            <Controller
              name="password"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor={`${id}su-pass`}>
                    {COPY.password}
                  </FieldLabel>
                  <Input
                    {...field}
                    id={`${id}su-pass`}
                    type="password"
                    autoComplete="new-password"
                    aria-invalid={fieldState.invalid}
                  />
                  {fieldState.invalid ? (
                    <FieldError errors={[fieldState.error]} />
                  ) : (
                    <FieldDescription>{COPY.passwordHint}</FieldDescription>
                  )}
                </Field>
              )}
            />
          </FieldGroup>
        </StepperContent>
        <StepperContent keepMounted value={1}>
          <FieldGroup>
            <Controller
              name="name"
              control={form.control}
              render={({ field, fieldState }) => (
                <Field data-invalid={fieldState.invalid}>
                  <FieldLabel htmlFor={`${id}su-name`}>{COPY.name}</FieldLabel>
                  <Input
                    {...field}
                    id={`${id}su-name`}
                    autoComplete="nickname"
                    aria-invalid={fieldState.invalid}
                  />
                  <FieldError errors={[fieldState.error]} />
                </Field>
              )}
            />
            <Controller
              name="role"
              control={form.control}
              render={({ field }) => (
                <Field>
                  <FieldLabel htmlFor={`${id}su-role`}>{COPY.role}</FieldLabel>
                  <Input
                    {...field}
                    id={`${id}su-role`}
                    placeholder="Designer"
                  />
                </Field>
              )}
            />
          </FieldGroup>
        </StepperContent>
        <StepperContent keepMounted value={2}>
          <FieldGroup>
            {COPY.prefs.map((p, i) => (
              <Controller
                key={p}
                name={`prefs.${i}` as const}
                control={form.control}
                render={({ field }) => (
                  <Field orientation="horizontal">
                    <Checkbox
                      id={`${id}su-pref-${i}`}
                      checked={field.value}
                      onCheckedChange={(v) => field.onChange(Boolean(v))}
                    />
                    <FieldLabel
                      htmlFor={`${id}su-pref-${i}`}
                      className="font-normal"
                    >
                      {p}
                    </FieldLabel>
                  </Field>
                )}
              />
            ))}
          </FieldGroup>
        </StepperContent>
        <StepperContent value={3}>
          <p className="text-sm" aria-live="polite">
            {opened
              ? COPY.opened
              : COPY.done(form.getValues("name") || "there")}
          </p>
        </StepperContent>

        <div className="flex justify-between">
          <Button
            type="button"
            variant="outline"
            disabled={step === 0 || opened}
            onClick={() => setStep(step - 1)}
          >
            {COPY.back}
          </Button>
          <Button type="submit" disabled={opened}>
            {step < last ? COPY.next : COPY.finish}
          </Button>
        </div>
      </Stepper>
    </form>
  )
}

Installation

pnpm dlx shadcn add @01orbital/signup-wizard-bars
npx shadcn add @01orbital/signup-wizard-bars
yarn shadcn add @01orbital/signup-wizard-bars
bunx --bun shadcn add @01orbital/signup-wizard-bars

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/blocks/stepper/signup-wizard-bars/index.tsx, as source you own and edit.

Usage

import SignupWizardBars from "@/components/blocks/stepper/signup-wizard-bars"

export default function Example() {
  return <SignupWizardBars />
}

Dependencies

npm: @hookform/resolvers, react-hook-form, zod. The CLI installs them for you.

Registry pieces it installs with it: