# Onboarding 01

> 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.

A shadcn/ui block from the [@hirael](https://hirael.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/hirael/onboarding-01).

- Page, with a live preview: https://shadcn.exchange/r/hirael/onboarding-01
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/onboarding-01-light-a2f854bc4a322f87.png
- Registry item: https://hirael.com/r/onboarding-01.json
- Categories: blocks, saas
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://hirael.com/r/onboarding-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `button`, `card`, `field`, `input`, `input-group`, `progress`, `radio-group`, `select`, `https://hirael.com/r/stepper.json`, `switch`

## Files

### `registry/hirael/bases/radix/blocks/onboarding-01/onboarding-01.tsx`

```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;
```
