Skip to content
← Blocks

Onboarding Wizard

A working onboarding wizard with illustrative data and frontend interactions.

Open ↗

Make it yours.

Install the editable source and its dependencies into your React + Tailwind project.

Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/onboarding-wizard.json

Use it

Scroll horizontally for long lines
"use client";
import { OnboardingWizard } from "@/components/jez-ui/blocks/onboarding-wizard";

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

Compose your own

These styled parts are included in the same install. Use children to build your layout; add className only when you want an override.

Scroll horizontally for long lines
"use client";
import {
  OnboardingWizard,
  OnboardingWizardSteps,
  OnboardingWizardStepContent,
} from "@/components/jez-ui/blocks/onboarding-wizard";

export default function Example() {
  return (
    <OnboardingWizard>
      <OnboardingWizardSteps />
      <OnboardingWizardStepContent />
    </OnboardingWizard>
  );
}
Scroll horizontally for long lines
import { OnboardingWizard, OnboardingWizardAside, OnboardingWizardContent, OnboardingWizardTitle, OnboardingWizardItem, OnboardingWizardSteps, OnboardingWizardStepContent } from "@/components/jez-ui/blocks/onboarding-wizard";
Read the composition guide →

Props & types

Scroll horizontally for long lines
export type OnboardingWizardOptions = {
  onComplete?: (value: {
    name: string;
    workspace: string;
    mode: string;
  }) => void | Promise<void>;
  className?: string;
  steps?: typeof OnboardingWizardDefaultSteps;
  modes?: typeof OnboardingWizardDefaultModes;
  step?: number;
  defaultStep?: number;
  onStepChange?: (value: number) => void;
};

export type OnboardingWizardProps = Omit<
  React.ComponentProps<"section">,
  keyof OnboardingWizardOptions
> &
  OnboardingWizardOptions;

OnboardingWizardProps

Source

View 6 source files

registry/blocks/onboarding-wizard.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import {
  ArrowRight,
  Check,
  Layers,
  Users,
  UserRound,
  ArrowLeft,
} from "lucide-react";
import { useControllable } from "../ui/use-controllable";
import { useAsyncAction } from "../ui/use-async-action";
import { cn } from "../ui/utils";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
export type OnboardingWizardOptions = {
  onComplete?: (value: {
    name: string;
    workspace: string;
    mode: string;
  }) => void | Promise<void>;
  className?: string;
  steps?: typeof OnboardingWizardDefaultSteps;
  modes?: typeof OnboardingWizardDefaultModes;
  step?: number;
  defaultStep?: number;
  onStepChange?: (value: number) => void;
};
export type OnboardingWizardProps = Omit<
  React.ComponentProps<"section">,
  keyof OnboardingWizardOptions
> &
  OnboardingWizardOptions;
const OnboardingWizardDefaultSteps = [
  "Your details",
  "Your workspace",
  "Ready to go",
];
const OnboardingWizardDefaultModes = ["Personal", "Team"];
function useOnboardingWizardModel({
  onComplete,
  step: suppliedValue,
  defaultStep = 0,
  onStepChange,
  steps = OnboardingWizardDefaultSteps,
  modes = OnboardingWizardDefaultModes,
  className,
  children,
  ...rootProps
}: OnboardingWizardProps) {
  const action = useAsyncAction();
  const [step, setStep] = useControllable<number>(
    suppliedValue,
    defaultStep,
    onStepChange,
  );
  const [name, setName] = React.useState("");
  const [workspace, setWorkspace] = React.useState("");
  const [mode, setMode] = React.useState("Team");
  return {
    onComplete,
    suppliedValue,
    defaultStep,
    onStepChange,
    steps,
    modes,
    className,
    children,
    rootProps,
    action,
    step,
    setStep,
    name,
    setName,
    workspace,
    setWorkspace,
    mode,
    setMode,
  };
}
const OnboardingWizardCompositionContext = React.createContext<ReturnType<
  typeof useOnboardingWizardModel
> | null>(null);
function useOnboardingWizardComposition() {
  const context = React.useContext(OnboardingWizardCompositionContext);
  if (!context)
    throw new Error("OnboardingWizard parts must be inside OnboardingWizard.");
  return context;
}
export function OnboardingWizard(props: OnboardingWizardProps) {
  const model = useOnboardingWizardModel(props);
  const { className, rootProps, children } = model;
  return (
    <OnboardingWizardCompositionContext.Provider value={model}>
      <section
        {...rootProps}
        className={cn(
          "grid overflow-hidden rounded-2xl border border-border md:grid-cols-[220px_minmax(0,1fr)]",
          className,
        )}
      >
        {children !== undefined ? (
          children
        ) : (
          <>
            <OnboardingWizardSteps />
            <OnboardingWizardStepContent />
          </>
        )}
      </section>
    </OnboardingWizardCompositionContext.Provider>
  );
}

export function OnboardingWizardAside({
  className,
  ...props
}: React.ComponentProps<"aside">) {
  return (
    <aside
      data-slot="onboarding-wizard-aside"
      className={cn(
        "border-b border-border bg-muted/40 p-6 md:border-r md:border-b-0",
        className,
      )}
      {...props}
    />
  );
}
export function OnboardingWizardContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="onboarding-wizard-content"
      className={cn("p-6 sm:p-9", className)}
      {...props}
    />
  );
}
export function OnboardingWizardTitle({
  className,
  ...props
}: React.ComponentProps<"h2">) {
  return (
    <h2
      data-slot="onboarding-wizard-title"
      className={cn("font-display text-2xl", className)}
      {...props}
    />
  );
}

export function OnboardingWizardItem({
  className,
  ...props
}: React.ComponentProps<"li">) {
  return (
    <li
      data-slot="onboarding-wizard-item"
      className={cn("flex items-center gap-3 text-sm", className)}
      {...props}
    />
  );
}

export function OnboardingWizardSteps({
  children,
  ...props
}: Partial<React.ComponentProps<typeof OnboardingWizardAside>> & {
  children?: React.ReactNode;
}) {
  const { steps, step } = useOnboardingWizardComposition();
  return (
    <OnboardingWizardAside {...props}>
      {children === undefined ? (
        <>
          <Layers size={25} />
          <p className="mb-7 mt-5 text-sm font-semibold">
            Make yourself at home
          </p>
          <ol className="flex gap-4 md:grid md:gap-6">
            {steps.map((label, i) => (
              <OnboardingWizardItem
                key={label}
                aria-current={i === step ? "step" : undefined}
              >
                <span
                  className={cn(
                    "grid size-6 shrink-0 place-items-center rounded-full border text-xs",
                    i <= step
                      ? "border-primary bg-primary text-primary-foreground"
                      : "border-border text-muted-foreground",
                  )}
                >
                  {i < step ? <Check size={13} /> : i + 1}
                </span>
                <span
                  className={cn(
                    "hidden sm:inline",
                    i !== step && "text-muted-foreground",
                  )}
                >
                  {label}
                </span>
              </OnboardingWizardItem>
            ))}
          </ol>
        </>
      ) : (
        children
      )}
    </OnboardingWizardAside>
  );
}
export function OnboardingWizardStepContent({
  children,
  ...props
}: Partial<React.ComponentProps<typeof OnboardingWizardContent>> & {
  children?: React.ReactNode;
}) {
  const {
    onComplete,
    modes,
    action,
    step,
    setStep,
    name,
    setName,
    workspace,
    setWorkspace,
    mode,
    setMode,
  } = useOnboardingWizardComposition();
  return (
    <OnboardingWizardContent {...props}>
      {children === undefined ? (
        <>
          {action.error && <p role="alert">{action.error}</p>}
          <p className="mb-2 text-xs text-muted-foreground">
            Step {step + 1} of 3
          </p>
          <OnboardingWizardTitle>
            {step === 0
              ? "First, a proper introduction."
              : step === 1
                ? "A space for your best work."
                : `You’re all set, ${name.split(" ")[0]}.`}
          </OnboardingWizardTitle>
          <p className="mb-7 mt-2 text-sm text-muted-foreground">
            {step === 0
              ? "Let’s put a name to the work."
              : step === 1
                ? "Name your workspace and choose how you’ll use it."
                : "Here’s the workspace you’ve put together."}
          </p>
          {step < 2 ? (
            <form
              className="grid gap-6"
              onSubmit={(e) => {
                e.preventDefault();
                if (step === 1)
                  void action.run(async () => {
                    await onComplete?.({ name, workspace, mode });
                    setStep(2);
                  });
                else setStep(1);
              }}
            >
              {step === 0 ? (
                <label className="grid gap-2 text-sm font-medium">
                  Your name
                  <Input
                    required
                    autoComplete="name"
                    placeholder="Alex Morgan"
                    value={name}
                    onChange={(e) => setName(e.target.value)}
                  />
                </label>
              ) : (
                <>
                  <label className="grid gap-2 text-sm font-medium">
                    Workspace name
                    <Input
                      required
                      placeholder="Acme Studio"
                      value={workspace}
                      onChange={(e) => setWorkspace(e.target.value)}
                    />
                  </label>
                  <fieldset>
                    <legend className="mb-3 text-sm font-medium">
                      Who’s joining you?
                    </legend>
                    <div className="grid grid-cols-2 gap-3">
                      {modes.map((value, i) => (
                        <label
                          key={value}
                          className={cn(
                            "relative cursor-pointer rounded-xl border p-4 transition-colors",
                            mode === value
                              ? "border-primary bg-primary/5"
                              : "border-border hover:bg-muted/40",
                          )}
                        >
                          <input
                            className="sr-only peer"
                            type="radio"
                            name="mode"
                            value={value}
                            checked={mode === value}
                            onChange={() => setMode(value)}
                          />
                          {i ? <Users size={20} /> : <UserRound size={20} />}
                          <span className="mt-3 block text-sm font-medium peer-focus-visible:underline">
                            {value}
                          </span>
                          <span className="mt-1 block text-xs text-muted-foreground">
                            {i
                              ? "Shared projects & handoffs"
                              : "A space of your own"}
                          </span>
                          {mode === value && (
                            <Check
                              className="absolute right-3 top-3"
                              size={14}
                            />
                          )}
                        </label>
                      ))}
                    </div>
                  </fieldset>
                </>
              )}
              <div className="mt-2 flex justify-between border-t border-border pt-5">
                {step > 0 ? (
                  <Button variant="ghost" onClick={() => setStep((s) => s - 1)}>
                    <ArrowLeft size={15} />
                    Back
                  </Button>
                ) : (
                  <span />
                )}
                <Button
                  type="submit"
                  loading={action.pending}
                  disabled={action.pending}
                >
                  Continue
                  <ArrowRight size={15} />
                </Button>
              </div>
            </form>
          ) : (
            <>
              <div className="mb-6 flex items-center gap-4 rounded-xl border border-border bg-muted/20 p-5">
                <span className="grid size-12 place-items-center rounded-xl bg-primary font-display text-xl text-primary-foreground">
                  {workspace.charAt(0).toUpperCase()}
                </span>
                <div>
                  <p className="font-medium">{workspace}</p>
                  <p className="mt-1 text-sm text-muted-foreground">
                    {mode} workspace · {name}
                  </p>
                </div>
                <Check className="ml-auto" size={20} />
              </div>
              <p className="mb-6 text-sm text-muted-foreground">
                Your choices are ready in this preview.
              </p>
              <Button variant="outline" onClick={() => setStep(0)}>
                Edit setup
              </Button>
            </>
          )}
        </>
      ) : (
        children
      )}
    </OnboardingWizardContent>
  );
}

registry/ui/use-controllable.ts

Scroll horizontally for long lines
"use client";
import { useRef, useState, type SetStateAction } from "react";
/** Controlled or instance-local state. Functional updates also work before a rerender. */
export function useControllable<T>(
  value: T | undefined,
  initial: T,
  onChange?: (value: T) => void,
) {
  const [local, setLocal] = useState(initial);
  const current = useRef(value === undefined ? local : value);
  current.current = value === undefined ? local : value;
  return [
    current.current,
    (action: SetStateAction<T>) => {
      const next =
        typeof action === "function"
          ? (action as (previous: T) => T)(current.current)
          : action;
      current.current = next;
      if (value === undefined) setLocal(next);
      onChange?.(next);
    },
  ] as const;
}

registry/ui/use-async-action.ts

Scroll horizontally for long lines
"use client";
import * as React from "react";
/** Keeps duplicate submissions out and reports failures without committing local success. */
export function useAsyncAction() {
  const [pending, setPending] = React.useState(false);
  const [error, setError] = React.useState<string>();
  const busy = React.useRef(false);
  async function run(action: () => void | Promise<void>) {
    if (busy.current) return false;
    busy.current = true;
    setPending(true);
    setError(undefined);
    try {
      await action();
      return true;
    } catch (error) {
      setError(
        error instanceof Error
          ? error.message
          : "The action failed. Please try again.",
      );
      return false;
    } finally {
      busy.current = false;
      setPending(false);
    }
  }
  return { run, pending, error };
}

registry/ui/utils.ts

Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
  return twMerge(clsx(values));
}

registry/ui/input.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
export function Input({ className, ...props }: React.ComponentProps<"input">) {
  return (
    <input
      className={cn(
        "block h-10 w-full min-w-0 rounded-lg border border-border bg-background px-3 text-sm leading-normal shadow-[0_1px_2px_#00000004] transition-[border-color,box-shadow] placeholder:text-muted-foreground/75 hover:border-foreground/25 focus:border-primary focus:outline-none focus:ring-3 focus:ring-primary/10 aria-invalid:border-danger aria-invalid:ring-danger/10 disabled:cursor-not-allowed disabled:bg-muted/50 disabled:opacity-50",
        className,
      )}
      {...props}
    />
  );
}

registry/ui/button.tsx

Scroll horizontally for long lines
"use client";
import * as React from "react";
import { Slot } from "radix-ui";
import { LoaderCircle } from "lucide-react";
import { cn } from "./utils";
export type ButtonProps = React.ComponentProps<"button"> & {
  variant?: "primary" | "secondary" | "outline" | "ghost" | "danger";
  size?: "sm" | "md" | "lg";
  loading?: boolean;
  asChild?: boolean;
};
export function Button({
  variant = "primary",
  size = "md",
  loading,
  asChild,
  className,
  children,
  disabled,
  ...props
}: ButtonProps) {
  const Comp = asChild ? Slot.Root : "button";
  return (
    <Comp
      type={asChild ? undefined : "button"}
      disabled={asChild ? undefined : disabled || loading}
      aria-disabled={disabled || loading || undefined}
      aria-busy={loading || undefined}
      className={cn(
        "relative inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-medium leading-none transition-[background-color,border-color,box-shadow,transform] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:scale-[.98] disabled:pointer-events-none disabled:opacity-45 aria-disabled:pointer-events-none [&_svg]:shrink-0",
        {
          "bg-primary text-primary-foreground shadow-[inset_0_1px_0_#ffffff26,0_1px_2px_#00000012] hover:bg-primary/90":
            variant === "primary",
          "border-border/60 bg-muted text-foreground hover:bg-muted/70":
            variant === "secondary",
          "border-border bg-background text-foreground shadow-sm hover:bg-muted/60":
            variant === "outline",
          "text-muted-foreground hover:bg-muted hover:text-foreground":
            variant === "ghost",
          "bg-danger text-danger-foreground hover:bg-danger/90":
            variant === "danger",
        },
        {
          "h-8 px-3 text-xs": size === "sm",
          "h-10 px-4": size === "md",
          "h-12 px-6": size === "lg",
        },
        className,
      )}
      {...props}
    >
      {asChild ? (
        children
      ) : (
        <>
          <span
            className={cn(
              "inline-flex min-w-0 flex-1 items-center justify-center gap-2",
              loading && "invisible",
            )}
          >
            {children}
          </span>
          {loading && (
            <LoaderCircle
              aria-hidden="true"
              className="absolute size-4 animate-spin motion-reduce:animate-none"
            />
          )}
        </>
      )}
    </Comp>
  );
}

Dependencies

lucide-react@0.577.0 · clsx@2.1.1 · tailwind-merge@3.5.0 · radix-ui@1.6.7

Accessibility & behaviour

Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.

Read the accessibility and performance guide →