Skip to content
oks-ui

Onboarding

Onboarding wizard

A guided SaaS setup flow with a persistent step rail (checkmarks for completed steps, a highlighted current step, and disabled future ones), a real SteppedForm driving validation and Back/Continue/Finish, and a contextual tips panel that changes with the active step.

onboarding-wizard.tsx

How it's built

1

The step rail is a separate element from SteppedForm's own header (`headerVariant="none"`) — SteppedForm's content and footer render as one self-contained block inside its own `<form>`, which can't be split across other grid columns, so the rail is a read-only mirror kept in sync via the same controlled `stepIndex`.

2

Each step's `fields` array is real — leaving Company Name empty and clicking Continue shows a genuine inline validation error and blocks advancing, exactly like SteppedForm's own schema validation.

3

The wallet step's "Total Cost" and the quick-add buttons drive a controlled `FormFieldSet` (`value`/`onChange`) so the same number feeds both the visible input and the derived total — no separate source of truth.

Source

One file · copy & paste
onboarding-wizard.tsx
"use client";

import { useState } from "react";
import { Button } from "oks-ui/button";
import { FormFieldSet } from "oks-ui/form-field-set";
import { SteppedForm } from "oks-ui/stepped-form";
import "oks-ui/button.css";
import "oks-ui/form-field-set.css";
import "oks-ui/stepped-form.css";
import { CheckIcon, PlusIcon, TrashIcon, UsersIcon, WalletIcon } from "../../showcase/icons";

// A price/label tag — "Create Value Sets".
function TagIcon({ size = 26 }: { size?: number }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M12.5 2.5H20a1.5 1.5 0 0 1 1.5 1.5v7.5a1.5 1.5 0 0 1-.44 1.06l-8.94 8.94a1.5 1.5 0 0 1-2.12 0l-7.06-7.06a1.5 1.5 0 0 1 0-2.12l8.94-8.94a1.5 1.5 0 0 1 1.06-.44Z" />
      <circle cx="16.5" cy="7.5" r="1.25" />
    </svg>
  );
}

// A bullhorn/megaphone shape — "Create Campaign".
function MegaphoneIcon({ size = 26 }: { size?: number }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M3 10v4a1 1 0 0 0 1 1h2l5 4V5L6 9H4a1 1 0 0 0-1 1Z" />
      <path d="M16 8.5a4 4 0 0 1 0 7" />
      <path d="M19 5.5a8 8 0 0 1 0 13" />
    </svg>
  );
}

// A palette with a brush — "Customize Website".
function PaletteIcon({ size = 26 }: { size?: number }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M12 3a9 8 0 1 0 0 16c1.1 0 1.6-.6 1.6-1.4 0-.4-.15-.75-.4-1a1.4 1.4 0 0 1 1-2.4h1.6A3.2 3.2 0 0 0 19 11c0-4.4-3.6-8-7-8Z" />
      <circle cx="8" cy="11" r="1" fill="currentColor" stroke="none" />
      <circle cx="8.5" cy="7.5" r="1" fill="currentColor" stroke="none" />
      <circle cx="14" cy="7" r="1" fill="currentColor" stroke="none" />
    </svg>
  );
}

type ValueRow = { id: number; name: string; points: number };

const CURRENCIES = [
  { label: "UAE Dirham (AED)", value: "aed" },
  { label: "US Dollar (USD)", value: "usd" },
  { label: "Points only", value: "points" },
];

const CAMPAIGN_TYPES = [
  { label: "Work anniversary", value: "anniversary" },
  { label: "Performance milestone", value: "performance" },
  { label: "Birthday", value: "birthday" },
];

const BRAND_COLORS = ["#2f6fed", "#1f9e6d", "#e0623a", "#7a4dd1", "#171923"];

const PRICE_PER_POINT = 0.1;

const STEP_META = [
  { key: "employees", title: "Onboard Employees", icon: <UsersIcon size={26} /> },
  { key: "currency", title: "Set Currency", icon: <WalletIcon size={26} /> },
  { key: "wallet", title: "Top-up Wallet", icon: <WalletIcon size={26} /> },
  { key: "values", title: "Create Value Sets", icon: <TagIcon /> },
  { key: "campaign", title: "Create Campaign", icon: <MegaphoneIcon /> },
  { key: "website", title: "Customize Website", icon: <PaletteIcon /> },
];

const TIPS: Record<string, { title: string; body: string }[]> = {
  employees: [
    { title: "1. Prepare your list", body: "A CSV with name, email, and department is all the importer needs." },
    { title: "2. Upload the list", body: "Drag it into the dropzone below — this can take a minute for larger teams." },
  ],
  currency: [
    { title: "1. Pick a currency", body: "This is what employees see when redeeming — you can add more later." },
    { title: "2. Set the exchange rate", body: "How many points equal one unit of currency in your reward catalog." },
  ],
  wallet: [
    { title: "1. Choose a starting balance", body: "5,000 points comfortably covers a small team's first quarter." },
    { title: "2. Review the cost", body: "Points are billed at a fixed rate and added to your wallet instantly." },
  ],
  values: [
    { title: "1. Name your values", body: "Short, specific values (\"Ownership\", \"Craft\") get nominated more often." },
    { title: "2. Attach a point reward", body: "Higher point rewards signal which values matter most to leadership." },
  ],
  campaign: [
    { title: "1. Pick a moment", body: "Recurring moments like anniversaries keep the program active year-round." },
    { title: "2. Write a short description", body: "Shown to managers when they're deciding who to nominate." },
  ],
  website: [
    { title: "1. Upload your logo", body: "SVG or PNG with a transparent background works best." },
    { title: "2. Pick a brand color", body: "Used across every employee-facing email and the redemption page." },
  ],
};

export function OnboardingWizard() {
  const [stepIndex, setStepIndex] = useState(0);
  const [maxStep, setMaxStep] = useState(0);
  const [points, setPoints] = useState(0);
  const [brandColor, setBrandColor] = useState(BRAND_COLORS[0]);
  const [values, setValues] = useState<ValueRow[]>([
    { id: 1, name: "Ownership", points: 50 },
    { id: 2, name: "Teamwork", points: 30 },
  ]);

  const goToStep = (next: number) => {
    setStepIndex(next);
    setMaxStep((m) => Math.max(m, next));
  };

  const steps = [
    {
      key: "employees",
      title: "Onboard Employees",
      fields: ["companyName"],
      content: (
        <div className="flex flex-col gap-4">
          {/* h2, not h3: this preview's own outline starts fresh at the
              pattern page's h1 with nothing in between, so h3 here (and on
              every other step's title below) would skip a level (axe's
              heading-order). */}
          <h2 className="text-[1.15rem] font-bold text-ink">Onboard Employees</h2>
          <p className="text-[0.85rem] text-ink-muted">Add your team so they can start receiving and redeeming rewards.</p>
          <FormFieldSet type="text" name="companyName" label="Company Name" validation={{ rules: { required: true } }} />
          <FormFieldSet type="file" name="employeeFile" label="Employee List" ui="dropzone" accept=".csv,.xlsx" />
          <FormFieldSet type="switch" name="autoInvite" label="Send invite emails automatically" description="Employees get an email as soon as they're added." />
        </div>
      ),
    },
    {
      key: "currency",
      title: "Set Currency",
      fields: ["currency"],
      content: (
        <div className="flex flex-col gap-4">
          <h2 className="text-[1.15rem] font-bold text-ink">Set Currency</h2>
          <p className="text-[0.85rem] text-ink-muted">Choose what employees see when they redeem their points.</p>
          <FormFieldSet type="select" name="currency" label="Reward Currency" options={CURRENCIES} validation={{ rules: { required: true } }} />
          <FormFieldSet type="number" name="exchangeRate" label="Points per unit" suffix="pts" defaultValue={10} />
        </div>
      ),
    },
    {
      key: "wallet",
      title: "Top-up Wallet",
      fields: [],
      content: (
        <div className="flex flex-col gap-4">
          <h2 className="text-[1.15rem] font-bold text-ink">Top-up Wallet</h2>
          <p className="text-[0.85rem] text-ink-muted">Set up a rewards budget and top up the wallet you&apos;ll pay employees from.</p>
          <FormFieldSet
            type="number"
            name="walletPoints"
            label="Enter Points to Add"
            description="Recommended: 5,000 points"
            value={points}
            onChange={(v: unknown) => setPoints(Number(v) || 0)}
            startIcon={<WalletIcon size={16} />}
          />
          <div className="flex flex-wrap gap-2">
            {[1000, 5000, 10000].map((amount) => (
              <Button key={amount} variant="bordered" size="sm" radius="full" startContent={<PlusIcon size={12} />} onClick={() => setPoints((p) => p + amount)}>
                {amount.toLocaleString("en-US")}
              </Button>
            ))}
          </div>
          <div className="mt-2 flex items-center justify-between border-t border-line pt-4">
            <span className="text-[0.9rem] text-ink-muted">Total Cost</span>
            <span className="text-[1.1rem] font-bold text-ink">AED {(points * PRICE_PER_POINT).toLocaleString("en-US")}</span>
          </div>
        </div>
      ),
    },
    {
      key: "values",
      title: "Create Value Sets",
      fields: [],
      content: (
        <div className="flex flex-col gap-4">
          <h2 className="text-[1.15rem] font-bold text-ink">Create Value Sets</h2>
          <p className="text-[0.85rem] text-ink-muted">Define the company values employees can nominate each other for.</p>
          <div className="flex flex-col gap-3">
            {values.map((row) => (
              <div key={row.id} className="grid grid-cols-[1fr_7rem_2.5rem] items-center gap-2">
                <FormFieldSet
                  type="text"
                  name={`value-${row.id}`}
                  label="Value name"
                  classNames={{ label: "sr-only" }}
                  value={row.name}
                  onChange={(v: unknown) => setValues((prev) => prev.map((r) => (r.id === row.id ? { ...r, name: String(v ?? "") } : r)))}
                />
                <FormFieldSet
                  type="number"
                  name={`points-${row.id}`}
                  label="Points"
                  classNames={{ label: "sr-only" }}
                  suffix="pts"
                  value={row.points}
                  onChange={(v: unknown) => setValues((prev) => prev.map((r) => (r.id === row.id ? { ...r, points: Number(v) || 0 } : r)))}
                />
                <Button isIconOnly aria-label={`Remove ${row.name || "value"}`} variant="ghost" size="sm" onClick={() => setValues((prev) => prev.filter((r) => r.id !== row.id))}>
                  <TrashIcon size={15} />
                </Button>
              </div>
            ))}
          </div>
          <Button
            variant="link"
            size="sm"
            className="self-start px-0"
            startContent={<PlusIcon size={14} />}
            onClick={() => setValues((prev) => [...prev, { id: Date.now(), name: "", points: 25 }])}
          >
            Add Value
          </Button>
        </div>
      ),
    },
    {
      key: "campaign",
      title: "Create Campaign",
      fields: ["campaignName", "campaignType"],
      content: (
        <div className="flex flex-col gap-4">
          <h2 className="text-[1.15rem] font-bold text-ink">Create Campaign</h2>
          <p className="text-[0.85rem] text-ink-muted">Set up a recurring moment worth celebrating.</p>
          <FormFieldSet type="text" name="campaignName" label="Campaign Name" validation={{ rules: { required: true } }} />
          <FormFieldSet type="select" name="campaignType" label="Campaign Type" options={CAMPAIGN_TYPES} validation={{ rules: { required: true } }} />
          <FormFieldSet type="textarea" name="campaignDescription" label="Description" rows={3} />
        </div>
      ),
    },
    {
      key: "website",
      title: "Customize Website",
      fields: [],
      content: (
        <div className="flex flex-col gap-4">
          <h2 className="text-[1.15rem] font-bold text-ink">Customize Website</h2>
          <p className="text-[0.85rem] text-ink-muted">Match the redemption page to your brand.</p>
          <FormFieldSet type="file" name="logo" label="Company Logo" ui="dropzone" accept="image/*" preview="thumbnails" />
          <FormFieldSet type="text" name="tagline" label="Tagline" placeholder="Great work, rewarded." />
          <div>
            <p className="mb-2 text-[0.85rem] font-medium text-ink">Brand Color</p>
            <div className="flex items-center gap-2">
              {BRAND_COLORS.map((c) => (
                <Button
                  key={c}
                  isIconOnly
                  radius="full"
                  size="sm"
                  aria-label={`Use ${c} as brand color`}
                  aria-pressed={brandColor === c}
                  onClick={() => setBrandColor(c)}
                  className="text-white ring-2 ring-offset-2 ring-offset-paper"
                  style={{ background: c, ["--tw-ring-color" as string]: brandColor === c ? c : "transparent" }}
                >
                  {brandColor === c ? <CheckIcon size={14} /> : null}
                </Button>
              ))}
            </div>
          </div>
        </div>
      ),
    },
  ];

  const activeTips = TIPS[STEP_META[stepIndex]?.key ?? "employees"];

  return (
    // @container has to live on its OWN wrapper, not the grid element whose
    // @3xl:grid-cols is conditioned by it — an element can't query its own
    // container for its own styling (circular per the CSS Containment
    // spec), so that utility would silently be evaluated against the
    // wrong (ancestor) container and never apply.
    <div className="@container w-full">
      <div className="grid items-start gap-5 bg-paper-sunken p-6 @3xl:grid-cols-[15rem_1fr_16rem]">
        {/* left — a plain presentational rail, kept in sync with the same
            controlled stepIndex/maxStep that drives SteppedForm below.
            It's a separate element (not SteppedForm's own header) because
            SteppedForm's content/footer render as one self-contained block
            inside its own <form>, which can't be split across other grid
            columns — so the real wizard body lives entirely in the center
            column, and this rail is a synced, read-only mirror of its
            step state. */}
        <nav aria-label="Onboarding steps" className="flex flex-col rounded-[var(--r-lg)] bg-paper p-4 shadow-[var(--shadow-sm)]">
          {STEP_META.map((step, i) => {
            const isCompleted = i < maxStep;
            const isActive = i === stepIndex;
            const isDisabled = i > maxStep;
            return (
              <div key={step.key} className="relative flex gap-3 pb-5 last:pb-0">
                {i < STEP_META.length - 1 ? (
                  <span aria-hidden="true" className="absolute left-6 top-[38px] h-[calc(100%-1rem)] w-px bg-line" />
                ) : null}
                <Button
                  variant="ghost"
                  fullWidth
                  isDisabled={isDisabled}
                  aria-current={isActive ? "step" : undefined}
                  onClick={() => goToStep(i)}
                  className={`justify-start gap-3 px-2 py-1.5 text-left ${isActive ? "bg-[color:var(--oks-color-primary-50)]" : ""}`}
                >
                  <span
                    className={`inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[0.8rem] font-semibold ${
                      isCompleted
                        ? "bg-[color:var(--oks-color-success-500)] text-white"
                        : isActive
                          ? "bg-[color:var(--oks-color-primary-600)] text-white"
                          : "bg-paper-sunken text-ink-faint"
                    }`}
                  >
                    {isCompleted ? <CheckIcon size={14} /> : i + 1}
                  </span>
                  <span className={`text-[0.88rem] font-medium ${isDisabled ? "text-ink-faint" : "text-ink"}`}>{step.title}</span>
                </Button>
              </div>
            );
          })}
        </nav>

        {/* center — the real wizard: SteppedForm owns validation, step
            advancement, and the Back/Next/Submit footer. headerVariant is
            "none" because the step rail is rendered separately above. */}
        <div className="rounded-[var(--r-lg)] bg-paper p-6 shadow-[var(--shadow-sm)]">
          <SteppedForm
            headerVariant="none"
            stepIndex={stepIndex}
            onStepChange={goToStep}
            onSubmit={(data) => console.log(data)}
            steps={steps}
            backLabel="Go Back"
            nextLabel="Continue"
            submitLabel="Finish Setup"
          />
        </div>

        {/* right — contextual tips, keyed off the same stepIndex. */}
        <div className="flex flex-col gap-5 rounded-[var(--r-lg)] bg-paper p-6 shadow-[var(--shadow-sm)]">
          <span className="flex h-12 w-12 items-center justify-center rounded-[var(--r-lg)] bg-[color:var(--oks-color-primary-50)] text-[color:var(--oks-color-primary-700)]">
            {STEP_META[stepIndex]?.icon}
          </span>
          <div className="flex flex-col gap-4">
            {activeTips.map((tip) => (
              <div key={tip.title}>
                <p className="text-[0.85rem] font-semibold text-ink">{tip.title}</p>
                <p className="text-[0.8rem] text-ink-muted">{tip.body}</p>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}

More patterns

App screens
Analytics dashboardA full fintech app screen — collapsible sidebar, header search, wallet cards, an earnings chart, savings goals, and a transactions table.
Auth
Sign-in screenA split-screen sign-in with a feature panel, Google SSO, and remember-me.
App screens
Settings panelA real settings screen — top tabs, two-column sections, and a sortable, selectable billing history table.