
Four-step setup wizard on the Stepper: controlled or uncontrolled step, per-step validation gating Continue, Skip, Back, a Finish that fires onComplete, and a fade between steps. Ships composable parts.
@hirael
registry/hirael/bases/radix/blocks/onboarding-01/onboarding-01.tsx
// Onboarding 01 from Hirael <https://hirael.com/blocks/saas/onboarding-01>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael
'use client';
import * as React from 'react';
import { ArrowLeft, ArrowRight, Check, Plus, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Card, CardContent } from '@/registry/hirael/bases/radix/ui/card';
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
FieldTitle,
} from '@/registry/hirael/bases/radix/ui/field';
import { Input } from '@/registry/hirael/bases/radix/ui/input';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/registry/hirael/bases/radix/ui/input-group';
import { Progress } from '@/registry/hirael/bases/radix/ui/progress';
import { RadioGroup, RadioGroupItem } from '@/registry/hirael/bases/radix/ui/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/registry/hirael/bases/radix/ui/select';
import { Switch } from '@/registry/hirael/bases/radix/ui/switch';
import {
Stepper,
StepperIndicator,
StepperItem,
StepperSeparator,
StepperTitle,
StepperTrigger,
} from '@/registry/hirael/bases/radix/components/stepper';
const ENTER =
'animate-in fade-in slide-in-from-bottom-2 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
const SWAP =
'animate-in fade-in slide-in-from-bottom-1 duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
interface OnboardingStepMeta {
title: string;
description?: string;
}
interface OnboardingCtx {
step: number;
total: number;
steps: readonly OnboardingStepMeta[];
isFirst: boolean;
isLast: boolean;
goTo: (step: number) => void;
next: () => void;
back: () => void;
complete: () => void;
}
const OnboardingContext = React.createContext<OnboardingCtx | null>(null);
const useOnboarding = () => {
const ctx = React.useContext(OnboardingContext);
if (!ctx) {
throw new Error('Onboarding parts must be used inside <Onboarding>');
}
return ctx;
};
interface OnboardingProps extends Omit<React.ComponentProps<'div'>, 'defaultValue'> {
/** Titles (and optional descriptions) for the indicator, in order. */
steps: readonly OnboardingStepMeta[];
/** Active step, 0-based. Controlled when set. */
step?: number;
defaultStep?: number;
onStepChange?: (step: number) => void;
/** Fires when Finish is pressed on the last step. */
onComplete?: () => void;
}
const Onboarding = ({
steps,
step: stepProp,
defaultStep = 0,
onStepChange,
onComplete,
className,
children,
...props
}: OnboardingProps) => {
const [internal, setInternal] = React.useState(defaultStep);
const total = steps.length;
const step = Math.min(Math.max(stepProp ?? internal, 0), Math.max(total - 1, 0));
const goTo = React.useCallback(
(next: number) => {
const clamped = Math.min(Math.max(next, 0), total - 1);
if (stepProp === undefined) setInternal(clamped);
onStepChange?.(clamped);
},
[stepProp, onStepChange, total],
);
const next = React.useCallback(() => goTo(step + 1), [goTo, step]);
const back = React.useCallback(() => goTo(step - 1), [goTo, step]);
const complete = React.useCallback(() => onComplete?.(), [onComplete]);
const ctx = React.useMemo<OnboardingCtx>(
() => ({
step,
total,
steps,
isFirst: step === 0,
isLast: step === total - 1,
goTo,
next,
back,
complete,
}),
[step, total, steps, goTo, next, back, complete],
);
return (
<OnboardingContext.Provider value={ctx}>
<div data-slot="onboarding" data-step={step} className={cn('flex w-full flex-col gap-6', className)} {...props}>
{children}
</div>
</OnboardingContext.Provider>
);
};
interface OnboardingHeaderProps extends React.ComponentProps<'div'> {
/** Hide the step titles under the indicators (they hide below `sm` anyway). */
showTitles?: boolean;
}
const OnboardingHeader = ({ showTitles = true, className, children, ...props }: OnboardingHeaderProps) => {
const { step, total, steps, goTo } = useOnboarding();
return (
<div data-slot="onboarding-header" className={cn('flex flex-col gap-4', className)} {...props}>
<div className="flex items-center justify-between gap-3">
<span aria-live="polite" className="text-xs text-muted-foreground uppercase">
Step {step + 1} of {total}
</span>
{children}
</div>
<Stepper value={step + 1} onValueChange={(s) => goTo(s - 1)}>
{steps.map((meta, i) => (
<StepperItem key={meta.title} step={i + 1} disabled={i > step}>
<StepperTrigger aria-label={`Step ${i + 1}: ${meta.title}`}>
<StepperIndicator className="size-7" />
{showTitles ? <StepperTitle className="hidden sm:block">{meta.title}</StepperTitle> : null}
</StepperTrigger>
{i < total - 1 ? <StepperSeparator /> : null}
</StepperItem>
))}
</Stepper>
</div>
);
};
type OnboardingProgressProps = React.ComponentProps<'div'>;
const OnboardingProgress = ({ className, ...props }: OnboardingProgressProps) => {
const { step, total } = useOnboarding();
const value = total > 0 ? Math.round(((step + 1) / total) * 100) : 0;
return (
<div data-slot="onboarding-progress" className={cn('flex items-center gap-3', className)} {...props}>
<Progress value={value} aria-label={`Step ${step + 1} of ${total}`} className="h-1 flex-1" />
<span className="text-xs text-muted-foreground tabular-nums">
{step + 1}/{total}
</span>
</div>
);
};
interface OnboardingStepProps extends React.ComponentProps<'div'> {
/** Which step this is, 0-based. Renders only while active. */
index: number;
}
const OnboardingStep = ({ index, className, children, ...props }: OnboardingStepProps) => {
const { step } = useOnboarding();
if (index !== step) return null;
return (
<div
data-slot="onboarding-step"
data-index={index}
className={cn('flex flex-col gap-5', SWAP, className)}
{...props}
>
{children}
</div>
);
};
type OnboardingStepTitleProps = React.ComponentProps<'h2'>;
const OnboardingStepTitle = ({ className, ...props }: OnboardingStepTitleProps) => {
return (
<h2
data-slot="onboarding-step-title"
className={cn('text-xl font-semibold tracking-[-0.02em] text-foreground', className)}
{...props}
/>
);
};
type OnboardingStepDescriptionProps = React.ComponentProps<'p'>;
const OnboardingStepDescription = ({ className, ...props }: OnboardingStepDescriptionProps) => {
return (
<p data-slot="onboarding-step-description" className={cn('text-sm text-muted-foreground', className)} {...props} />
);
};
type OnboardingBodyProps = React.ComponentProps<'div'>;
const OnboardingBody = ({ className, ...props }: OnboardingBodyProps) => {
return <div data-slot="onboarding-body" className={cn('flex flex-col gap-5', className)} {...props} />;
};
interface OnboardingFooterProps extends React.ComponentProps<'div'> {
/** Gate Continue / Finish on the current step being valid. */
canContinue?: boolean;
/** Show a Skip button that advances without validating. */
skippable?: boolean;
onSkip?: () => void;
backLabel?: string;
continueLabel?: string;
finishLabel?: string;
skipLabel?: string;
}
const OnboardingFooter = ({
canContinue = true,
skippable = false,
onSkip,
backLabel = 'Back',
continueLabel = 'Continue',
finishLabel = 'Finish',
skipLabel = 'Skip for now',
className,
children,
...props
}: OnboardingFooterProps) => {
const { isFirst, isLast, next, back, complete } = useOnboarding();
const skip = () => {
onSkip?.();
if (isLast) complete();
else next();
};
return (
<div
data-slot="onboarding-footer"
className={cn('flex items-center justify-between gap-2 border-t border-border pt-5', className)}
{...props}
>
<Button type="button" variant="ghost" size="sm" disabled={isFirst} onClick={back}>
<ArrowLeft className="size-3.5 rtl:rotate-180" aria-hidden />
{backLabel}
</Button>
<div className="flex items-center gap-2">
{children}
{skippable ? (
<Button type="button" variant="ghost" size="sm" onClick={skip}>
{skipLabel}
</Button>
) : null}
<Button type="button" size="sm" disabled={!canContinue} onClick={isLast ? complete : next}>
{isLast ? finishLabel : continueLabel}
{isLast ? (
<Check className="size-3.5" aria-hidden />
) : (
<ArrowRight className="size-3.5 rtl:rotate-180" aria-hidden />
)}
</Button>
</div>
</div>
);
};
export {
Onboarding,
OnboardingHeader,
OnboardingProgress,
OnboardingStep,
OnboardingStepTitle,
OnboardingStepDescription,
OnboardingBody,
OnboardingFooter,
useOnboarding,
type OnboardingStepMeta,
};
const STEPS: readonly OnboardingStepMeta[] = [
{ title: 'Workspace' },
{ title: 'Profile' },
{ title: 'Team' },
{ title: 'Preferences' },
];
const ROLES = [
{ value: 'founder', label: 'Founder' },
{ value: 'engineer', label: 'Engineer' },
{ value: 'designer', label: 'Designer' },
{ value: 'product', label: 'Product' },
{ value: 'other', label: 'Something else' },
];
const THEMES = [
{
value: 'system',
label: 'System',
description: 'Follow the operating system.',
},
{ value: 'dark', label: 'Dark', description: 'Easy on the eyes at night.' },
{ value: 'light', label: 'Light', description: 'Bright and high contrast.' },
];
const MAX_INVITES = 6;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const slugify = (value: string) => {
return value
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32);
};
/** Seeded so the first step opens with an enabled primary action. */
const DEMO_WORKSPACE = 'Hirael';
const Onboarding01 = () => {
const [step, setStep] = React.useState(0);
const [done, setDone] = React.useState(false);
const [workspace, setWorkspace] = React.useState(DEMO_WORKSPACE);
const [slug, setSlug] = React.useState('');
const [slugTouched, setSlugTouched] = React.useState(false);
const [fullName, setFullName] = React.useState('');
const [role, setRole] = React.useState('');
const [invites, setInvites] = React.useState<string[]>(['', '', '']);
const [theme, setTheme] = React.useState('system');
const [notify, setNotify] = React.useState(true);
const effectiveSlug = slugTouched ? slug : slugify(workspace);
const invitesValid = invites.every((e) => !e.trim() || EMAIL_RE.test(e));
const invitedCount = invites.filter((e) => EMAIL_RE.test(e)).length;
const canContinue = [
workspace.trim().length > 1 && effectiveSlug.length > 1,
fullName.trim().length > 1 && role !== '',
invitesValid,
true,
][step];
const restart = () => {
setDone(false);
setStep(0);
setWorkspace(DEMO_WORKSPACE);
setSlug('');
setSlugTouched(false);
setFullName('');
setRole('');
setInvites(['', '', '']);
setTheme('system');
setNotify(true);
};
return (
<section
data-slot="onboarding-01-block"
className="flex min-h-svh w-full items-center justify-center bg-background px-4 py-10 sm:px-6"
>
<Card className={cn(ENTER, 'w-full max-w-xl')}>
<CardContent>
{done ? (
<div
data-slot="onboarding-done"
className="flex animate-in flex-col items-center gap-5 py-6 text-center duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both fade-in zoom-in-97 motion-reduce:animate-none"
>
<div className="flex flex-col items-center gap-1.5">
<h2 className="flex items-center gap-2 text-xl font-semibold tracking-[-0.02em] text-foreground">
<Check className="size-5 shrink-0 text-success" strokeWidth={2.5} aria-hidden />
{workspace.trim() || 'Your workspace'} is ready.
</h2>
<p className="max-w-sm text-sm text-muted-foreground">
{invitedCount > 0 ? `We sent ${invitedCount} ${invitedCount === 1 ? 'invite' : 'invites'}. ` : ''}
You can change any of this later in Settings.
</p>
</div>
<Button type="button" size="lg" onClick={restart}>
Set up another workspace
<ArrowRight className="size-4 rtl:rotate-180" aria-hidden />
</Button>
</div>
) : (
<Onboarding steps={STEPS} step={step} onStepChange={setStep} onComplete={() => setDone(true)}>
<OnboardingHeader />
<OnboardingStep index={0}>
<div className="flex flex-col gap-1">
<OnboardingStepTitle>Name your workspace</OnboardingStepTitle>
<OnboardingStepDescription>
Usually your company or team name. Teammates will see it.
</OnboardingStepDescription>
</div>
<OnboardingBody>
<FieldGroup className="gap-5">
<Field className="gap-2">
<FieldLabel htmlFor="onboarding-workspace">Workspace name</FieldLabel>
<Input
id="onboarding-workspace"
value={workspace}
onChange={(e) => setWorkspace(e.target.value)}
placeholder="Hirael Labs"
autoComplete="organization"
autoFocus
/>
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="onboarding-slug">Workspace URL</FieldLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<span className="text-muted-foreground">hirael.com/</span>
</InputGroupAddon>
<InputGroupInput
id="onboarding-slug"
value={effectiveSlug}
onChange={(e) => {
setSlugTouched(true);
setSlug(slugify(e.target.value));
}}
placeholder="hirael-labs"
spellCheck={false}
autoComplete="off"
/>
</InputGroup>
<FieldDescription>
{effectiveSlug ? (
<>
Your team signs in at <span className="text-foreground">hirael.com/{effectiveSlug}</span>.
</>
) : (
'Lowercase letters, numbers, and dashes. Derived from the name until you edit it.'
)}
</FieldDescription>
</Field>
</FieldGroup>
</OnboardingBody>
<OnboardingFooter canContinue={canContinue} />
</OnboardingStep>
<OnboardingStep index={1}>
<div className="flex flex-col gap-1">
<OnboardingStepTitle>Tell us about you</OnboardingStepTitle>
<OnboardingStepDescription>
Shown on comments and activity. Your role tunes the defaults.
</OnboardingStepDescription>
</div>
<OnboardingBody>
<FieldGroup className="gap-5">
<Field className="gap-2">
<FieldLabel htmlFor="onboarding-name">Full name</FieldLabel>
<Input
id="onboarding-name"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Mohammad Shehadeh"
autoComplete="name"
autoFocus
/>
</Field>
<Field className="gap-2">
<FieldLabel htmlFor="onboarding-role">What do you do?</FieldLabel>
<Select value={role} onValueChange={setRole}>
<SelectTrigger id="onboarding-role" className="w-full">
<SelectValue placeholder="Pick a role" />
</SelectTrigger>
<SelectContent>
{ROLES.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</FieldGroup>
</OnboardingBody>
<OnboardingFooter canContinue={canContinue} />
</OnboardingStep>
<OnboardingStep index={2}>
<div className="flex flex-col gap-1">
<OnboardingStepTitle>Invite your team</OnboardingStepTitle>
<OnboardingStepDescription>
They join as members. You can change roles once they accept.
</OnboardingStepDescription>
</div>
<OnboardingBody>
<div className="flex flex-col gap-2">
{invites.map((email, i) => {
const invalid = email.trim() !== '' && !EMAIL_RE.test(email);
return (
<div key={i} className="flex items-center gap-2">
<Input
type="email"
value={email}
aria-label={`Teammate ${i + 1} email`}
aria-invalid={invalid || undefined}
onChange={(e) => setInvites((list) => list.map((v, j) => (j === i ? e.target.value : v)))}
placeholder={
['lena@hirael.com', 'omar@hirael.com', 'priya@hirael.com'][i] ?? 'name@hirael.com'
}
autoComplete="off"
autoFocus={i === 0}
/>
{invites.length > 1 ? (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Remove teammate ${i + 1}`}
onClick={() => setInvites((list) => list.filter((_, j) => j !== i))}
className="size-9 shrink-0"
>
<X className="size-4" aria-hidden />
</Button>
) : null}
</div>
);
})}
<Button
type="button"
variant="ghost"
size="sm"
disabled={invites.length >= MAX_INVITES}
onClick={() => setInvites((list) => [...list, ''])}
className="self-start"
>
<Plus className="size-3.5" aria-hidden />
Add another
</Button>
</div>
<p className="text-xs text-muted-foreground" aria-live="polite">
{invitedCount === 0
? 'Leave these empty to skip. You can invite people any time.'
: `${invitedCount} ${invitedCount === 1 ? 'person' : 'people'} will get an invite.`}
</p>
</OnboardingBody>
<OnboardingFooter canContinue={canContinue} skippable onSkip={() => setInvites(['', '', ''])} />
</OnboardingStep>
<OnboardingStep index={3}>
<div className="flex flex-col gap-1">
<OnboardingStepTitle>A few preferences</OnboardingStepTitle>
<OnboardingStepDescription>
Both of these live in Settings if you change your mind.
</OnboardingStepDescription>
</div>
<OnboardingBody>
<FieldSet>
<FieldLegend variant="label" className="mb-2">
Appearance
</FieldLegend>
<RadioGroup value={theme} onValueChange={setTheme} className="grid sm:grid-cols-3">
{THEMES.map((t) => {
const id = `onboarding-theme-${t.value}`;
return (
<FieldLabel key={t.value} htmlFor={id}>
<Field orientation="horizontal" className="gap-3">
<FieldContent className="gap-0.5">
<FieldTitle>{t.label}</FieldTitle>
<FieldDescription>{t.description}</FieldDescription>
</FieldContent>
<RadioGroupItem id={id} value={t.value} />
</Field>
</FieldLabel>
);
})}
</RadioGroup>
</FieldSet>
<div className="rounded-md border border-border p-3">
<Field orientation="horizontal" className="items-center justify-between">
<FieldContent className="gap-0.5">
<FieldLabel htmlFor="onboarding-notify">Email me a weekly digest</FieldLabel>
<FieldDescription>
What changed, who joined, and what needs a look. Sent Monday mornings.
</FieldDescription>
</FieldContent>
<Switch id="onboarding-notify" checked={notify} onCheckedChange={setNotify} />
</Field>
</div>
</OnboardingBody>
<OnboardingFooter canContinue={canContinue} />
</OnboardingStep>
</Onboarding>
)}
</CardContent>
</Card>
</section>
);
};
export default Onboarding01;































