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.
How it's built
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`.
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.
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"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'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>
);
}