# Onboarding

> A step-by-step flow with a segmented progress bar and CSS-only step transitions

A React component for projects built on shadcn/ui. The [interactive docs](https://alexandrejoly.me/components/onboarding) show each example live.

```tsx
import { Button } from "@/components/ui/button";
import {
  Onboarding,
  OnboardingContent,
  OnboardingDescription,
  OnboardingFooter,
  OnboardingHeader,
  OnboardingIndicator,
  OnboardingMedia,
  OnboardingNav,
  OnboardingNextButton,
  OnboardingPrevButton,
  OnboardingStep,
  OnboardingTitle,
} from "@/components/ui/onboarding";
import { useState } from "react";

const STEPS = [1, 2, 3].map((step) => ({
  description: `A short description of step ${step}.`,
  title: `Step ${step}`,
}));

export const OnboardingDemo = () => {
  const [completed, setCompleted] = useState(false);

  if (completed) {
    return (
      <div className="flex min-h-76.5 flex-col items-center justify-center gap-4">
        <p>Onboarding complete</p>
        <Button onClick={() => setCompleted(false)} variant="outline">
          Restart
        </Button>
      </div>
    );
  }

  return (
    <Onboarding
      className="w-full max-w-sm"
      onComplete={() => setCompleted(true)}
      totalSteps={STEPS.length}
    >
      <OnboardingContent>
        {STEPS.map(({ description, title }, index) => (
          <OnboardingStep key={title} value={index + 1}>
            <OnboardingMedia>
              <div
                aria-hidden
                className="h-40 w-full rounded-xl bg-black/35 dark:bg-white/35"
              />
            </OnboardingMedia>
            <OnboardingHeader>
              <OnboardingTitle>{title}</OnboardingTitle>
              <OnboardingDescription>{description}</OnboardingDescription>
            </OnboardingHeader>
          </OnboardingStep>
        ))}
      </OnboardingContent>
      <OnboardingFooter>
        <OnboardingIndicator />
        <OnboardingNav>
          <OnboardingPrevButton />
          <OnboardingNextButton />
        </OnboardingNav>
      </OnboardingFooter>
    </Onboarding>
  );
};
```

## Installation

With the shadcn CLI:

```bash
pnpm dlx shadcn@latest add @alexandrejoly/onboarding
```

Without the `@alexandrejoly` namespace, pass the registry URL:

```bash
pnpm dlx shadcn@latest add https://alexandrejoly.me/r/onboarding.json
```

Or by hand:

1. Install the dependencies:

   ```bash
   pnpm add @base-ui/react
   ```
2. Add the shadcn components it builds on:

   ```bash
   pnpm dlx shadcn@latest add button progress
   ```
3. Copy the source into `components/ui/onboarding.tsx`, then update the import paths to match your aliases:

   ```tsx
   "use client";

   import { mergeProps } from "@base-ui/react/merge-props";
   import {
     createContext,
     use,
     useCallback,
     useEffect,
     useLayoutEffect,
     useMemo,
     useRef,
     useState,
   } from "react";
   import type { Dispatch, ReactNode, Ref, SetStateAction } from "react";
   import { flushSync } from "react-dom";

   import { Button } from "@/components/ui/button";
   import { Progress } from "@/components/ui/progress";
   import { cn } from "@/lib/utils";

   interface UseOnboardingReturn {
     canGoToNextStep: boolean;
     canGoToPrevStep: boolean;
     currentStep: number;
     goToNextStep: () => void;
     goToPrevStep: () => void;
     reset: () => void;
     setStep: Dispatch<SetStateAction<number>>;
     totalSteps: number;
   }

   type OnboardingDirection = "next" | "previous";

   interface OnboardingContextValue {
     api: UseOnboardingReturn;
     direction?: OnboardingDirection;
     onComplete?: () => void;
   }

   const OnboardingContext = createContext<OnboardingContextValue | null>(null);

   function useOnboardingContext() {
     const context = use(OnboardingContext);
     if (!context) {
       throw new Error(
         "useOnboarding must be used within an Onboarding component"
       );
     }
     return context;
   }

   function useOnboarding(): UseOnboardingReturn {
     return useOnboardingContext().api;
   }

   interface OnboardingProps extends React.ComponentProps<"div"> {
     defaultValue?: number;
     onComplete?: () => void;
     totalSteps: number;
   }

   function Onboarding({
     children,
     className,
     defaultValue = 1,
     onComplete,
     totalSteps,
     ...props
   }: OnboardingProps) {
     const [currentStep, setCurrentStep] = useState(defaultValue);
     const [direction, setDirection] = useState<OnboardingDirection>();
     const canGoToNextStep = currentStep < totalSteps;
     const canGoToPrevStep = currentStep > 1;

     const goToStep = useCallback(
       (nextStep: number) => {
         if (nextStep === currentStep) {
           return;
         }
         setDirection(nextStep > currentStep ? "next" : "previous");
         setCurrentStep(nextStep);
       },
       [currentStep]
     );

     const setStep = useCallback<Dispatch<SetStateAction<number>>>(
       (step) => {
         const nextStep = typeof step === "function" ? step(currentStep) : step;
         if (nextStep < 1 || nextStep > totalSteps) {
           throw new Error(`Step ${nextStep} is out of range 1–${totalSteps}`);
         }
         goToStep(nextStep);
       },
       [currentStep, goToStep, totalSteps]
     );

     const api = useMemo<UseOnboardingReturn>(
       () => ({
         canGoToNextStep,
         canGoToPrevStep,
         currentStep,
         goToNextStep: () => {
           if (canGoToNextStep) {
             goToStep(currentStep + 1);
           }
         },
         goToPrevStep: () => {
           if (canGoToPrevStep) {
             goToStep(currentStep - 1);
           }
         },
         reset: () => goToStep(1),
         setStep,
         totalSteps,
       }),
       [
         canGoToNextStep,
         canGoToPrevStep,
         currentStep,
         goToStep,
         setStep,
         totalSteps,
       ]
     );

     const context = useMemo<OnboardingContextValue>(
       () => ({ api, direction, onComplete }),
       [api, direction, onComplete]
     );

     return (
       <OnboardingContext value={context}>
         <div
           className={cn("flex flex-col gap-6", className)}
           data-direction={direction}
           data-slot="onboarding"
           {...props}
         >
           {children}
         </div>
       </OnboardingContext>
     );
   }

   interface OnboardingStepProps extends React.ComponentProps<"div"> {
     value: number;
   }

   function OnboardingContent(props: React.ComponentProps<"div">) {
     return <div data-slot="onboarding-content" {...props} />;
   }

   type TransitionStatus = "starting" | "ending" | undefined;

   const runningAnimations = (element: HTMLElement) =>
     element
       .getAnimations?.({ subtree: true })
       .filter(
         (animation) =>
           animation.playState === "running" &&
           animation.effect?.getComputedTiming().endTime !==
             Number.POSITIVE_INFINITY
       ) ?? [];

   function assignRef<T>(ref: Ref<T> | undefined, node: T | null) {
     if (typeof ref === "function") {
       return ref(node);
     }
     if (ref) {
       ref.current = node;
     }
   }

   function useStepTransition(isCurrent: boolean) {
     const ref = useRef<HTMLDivElement>(null);
     const [mounted, setMounted] = useState(isCurrent);
     const [status, setStatus] = useState<TransitionStatus>();
     const [wasCurrent, setWasCurrent] = useState(isCurrent);

     if (isCurrent !== wasCurrent) {
       setWasCurrent(isCurrent);
       setMounted(true);
       if (!isCurrent) {
         setStatus("ending");
       } else if (mounted) {
         setStatus(undefined);
       } else {
         setStatus("starting");
       }
     }

     useEffect(() => {
       if (status !== "starting") {
         return;
       }
       const frame = requestAnimationFrame(() => setStatus(undefined));
       return () => cancelAnimationFrame(frame);
     }, [status]);

     useLayoutEffect(() => {
       const element = ref.current;
       if (status !== "ending" || !element) {
         return;
       }
       let cancelled = false;
       const unmountWhenDone = async () => {
         const unmount = () => {
           setMounted(false);
           setStatus(undefined);
         };
         let animations = runningAnimations(element);
         if (animations.length === 0) {
           unmount();
           return;
         }
         while (animations.length > 0) {
           await Promise.allSettled(
             animations.map((animation) => animation.finished)
           );
           if (cancelled) {
             return;
           }
           animations = runningAnimations(element);
         }
         flushSync(unmount);
       };
       void unmountWhenDone();
       return () => {
         cancelled = true;
       };
     }, [status]);

     return { mounted, ref, status };
   }

   function OnboardingStep({
     className,
     ref: forwardedRef,
     value,
     ...props
   }: OnboardingStepProps) {
     const { currentStep } = useOnboarding();
     const { direction } = useOnboardingContext();
     const { mounted, ref, status } = useStepTransition(value === currentStep);

     const setRefs = useCallback(
       (node: HTMLDivElement | null) => {
         ref.current = node;
         return assignRef(forwardedRef, node);
       },
       [forwardedRef, ref]
     );

     if (!mounted) {
       return null;
     }

     return (
       <div
         className={cn("flex flex-col gap-4", className)}
         data-direction={direction}
         data-ending-style={status === "ending" ? "" : undefined}
         data-slot="onboarding-step"
         data-starting-style={status === "starting" ? "" : undefined}
         data-step={value}
         inert={status === "ending"}
         ref={setRefs}
         {...props}
       />
     );
   }

   function OnboardingMedia({ className, ...props }: React.ComponentProps<"div">) {
     return (
       <div
         className={cn(
           "flex items-center justify-center overflow-hidden",
           className
         )}
         data-slot="onboarding-media"
         {...props}
       />
     );
   }

   function OnboardingHeader({
     className,
     ...props
   }: React.ComponentProps<"div">) {
     return (
       <div
         className={cn("flex flex-col gap-1", className)}
         data-slot="onboarding-header"
         {...props}
       />
     );
   }

   function OnboardingTitle({
     children,
     className,
     ...props
   }: React.ComponentProps<"h2">) {
     return (
       <h2
         className={cn("text-xl font-bold", className)}
         data-slot="onboarding-title"
         {...props}
       >
         {children}
       </h2>
     );
   }

   function OnboardingDescription({
     className,
     ...props
   }: React.ComponentProps<"p">) {
     return (
       <p
         className={cn("text-muted-foreground", className)}
         data-slot="onboarding-description"
         {...props}
       />
     );
   }

   function OnboardingFooter({
     className,
     ...props
   }: React.ComponentProps<"div">) {
     return (
       <div
         className={cn("flex flex-col gap-3", className)}
         data-slot="onboarding-footer"
         {...props}
       />
     );
   }

   function OnboardingIndicator({
     className,
     ...props
   }: React.ComponentProps<"div">) {
     const { currentStep, totalSteps } = useOnboarding();
     const steps = Array.from({ length: totalSteps }, (_, index) => index + 1);

     return (
       <div
         aria-label="Onboarding progress"
         aria-valuemax={totalSteps}
         aria-valuemin={1}
         aria-valuenow={currentStep}
         aria-valuetext={`Step ${currentStep} of ${totalSteps}`}
         className={cn("flex gap-1", className)}
         data-slot="onboarding-indicator"
         role="progressbar"
         {...props}
       >
         {steps.map((step) => (
           <Progress
             aria-hidden
             className="flex-1"
             key={step}
             value={step <= currentStep ? 100 : 0}
           />
         ))}
       </div>
     );
   }

   function OnboardingNav({ className, ...props }: React.ComponentProps<"div">) {
     return (
       <div
         className={cn("flex items-center justify-between", className)}
         data-slot="onboarding-nav"
         {...props}
       />
     );
   }

   interface OnboardingPrevButtonProps extends React.ComponentProps<
     typeof Button
   > {
     children?: ReactNode;
   }

   function OnboardingPrevButton({
     children = "Previous",
     ...props
   }: OnboardingPrevButtonProps) {
     const { goToPrevStep, canGoToPrevStep } = useOnboarding();

     return (
       <Button
         data-slot="onboarding-prev-button"
         {...mergeProps<typeof Button>(
           {
             disabled: !canGoToPrevStep,
             focusableWhenDisabled: true,
             onClick: goToPrevStep,
             variant: "ghost",
           },
           props
         )}
       >
         {children}
       </Button>
     );
   }

   interface OnboardingNextButtonProps extends React.ComponentProps<
     typeof Button
   > {
     children?: ReactNode;
     lastLabel?: ReactNode;
   }

   function OnboardingNextButton({
     children = "Continue",
     lastLabel = "Got it",
     ...props
   }: OnboardingNextButtonProps) {
     const { goToNextStep, canGoToNextStep } = useOnboarding();
     const { onComplete } = useOnboardingContext();
     const isLastStep = !canGoToNextStep;

     const handleClick = () => {
       if (isLastStep) {
         onComplete?.();
       } else {
         goToNextStep();
       }
     };

     return (
       <Button
         data-slot="onboarding-next-button"
         {...mergeProps<typeof Button>(
           { onClick: handleClick, variant: "default" },
           props
         )}
       >
         {isLastStep ? lastLabel : children}
       </Button>
     );
   }

   export {
     Onboarding,
     OnboardingContent,
     OnboardingDescription,
     OnboardingFooter,
     OnboardingHeader,
     OnboardingIndicator,
     OnboardingMedia,
     OnboardingNav,
     OnboardingNextButton,
     OnboardingPrevButton,
     OnboardingStep,
     OnboardingTitle,
     useOnboarding,
   };
   export type { UseOnboardingReturn };
   ```

## Usage

```tsx
import {
  Onboarding,
  OnboardingContent,
  OnboardingDescription,
  OnboardingFooter,
  OnboardingHeader,
  OnboardingIndicator,
  OnboardingNav,
  OnboardingNextButton,
  OnboardingPrevButton,
  OnboardingStep,
  OnboardingTitle,
} from "@/components/ui/onboarding";
```

```tsx
<Onboarding onComplete={() => setOpen(false)} totalSteps={2}>
  <OnboardingContent>
    <OnboardingStep value={1}>
      <OnboardingHeader>
        <OnboardingTitle>Step 1</OnboardingTitle>
        <OnboardingDescription>A short description of step 1.</OnboardingDescription>
      </OnboardingHeader>
    </OnboardingStep>
    <OnboardingStep value={2}>
      <OnboardingHeader>
        <OnboardingTitle>Step 2</OnboardingTitle>
        <OnboardingDescription>A short description of step 2.</OnboardingDescription>
      </OnboardingHeader>
    </OnboardingStep>
  </OnboardingContent>
  <OnboardingFooter>
    <OnboardingIndicator />
    <OnboardingNav>
      <OnboardingPrevButton />
      <OnboardingNextButton />
    </OnboardingNav>
  </OnboardingFooter>
</Onboarding>
```

## Examples

### useOnboarding

`useOnboarding` drives custom controls: a step counter, then a skip button that completes the flow and turns into a restart on the last step.

```tsx
import { Button } from "@/components/ui/button";
import {
  Onboarding,
  OnboardingContent,
  OnboardingDescription,
  OnboardingFooter,
  OnboardingHeader,
  OnboardingIndicator,
  OnboardingNav,
  OnboardingNextButton,
  OnboardingPrevButton,
  OnboardingStep,
  OnboardingTitle,
  useOnboarding,
} from "@/components/ui/onboarding";
import { useState } from "react";

const StepHeader = ({ onSkip }: { onSkip: () => void }) => {
  const { canGoToNextStep, currentStep, reset, totalSteps } = useOnboarding();

  return (
    <div className="flex items-center justify-between">
      <p>
        Step {currentStep} of {totalSteps}
      </p>
      {canGoToNextStep ? (
        <Button onClick={onSkip} variant="ghost">
          Skip
        </Button>
      ) : (
        <Button onClick={reset} variant="ghost">
          Restart
        </Button>
      )}
    </div>
  );
};

export const OnboardingUseOnboarding = () => {
  const [completed, setCompleted] = useState(false);

  if (completed) {
    return (
      <div className="flex min-h-46.5 flex-col items-center justify-center gap-4">
        <p>Onboarding complete</p>
        <Button onClick={() => setCompleted(false)} variant="outline">
          Restart
        </Button>
      </div>
    );
  }

  return (
    <Onboarding
      className="w-full max-w-sm"
      onComplete={() => setCompleted(true)}
      totalSteps={3}
    >
      <StepHeader onSkip={() => setCompleted(true)} />
      <OnboardingContent>
        <OnboardingStep value={1}>
          <OnboardingHeader>
            <OnboardingTitle>Step 1</OnboardingTitle>
            <OnboardingDescription>
              A short description of step 1.
            </OnboardingDescription>
          </OnboardingHeader>
        </OnboardingStep>
        <OnboardingStep value={2}>
          <OnboardingHeader>
            <OnboardingTitle>Step 2</OnboardingTitle>
            <OnboardingDescription>
              A short description of step 2.
            </OnboardingDescription>
          </OnboardingHeader>
        </OnboardingStep>
        <OnboardingStep value={3}>
          <OnboardingHeader>
            <OnboardingTitle>Step 3</OnboardingTitle>
            <OnboardingDescription>
              A short description of step 3.
            </OnboardingDescription>
          </OnboardingHeader>
        </OnboardingStep>
      </OnboardingContent>
      <OnboardingFooter>
        <OnboardingIndicator />
        <OnboardingNav>
          <OnboardingPrevButton />
          <OnboardingNextButton />
        </OnboardingNav>
      </OnboardingFooter>
    </Onboarding>
  );
};
```

### Transition

The text slides in from the right on Continue and from the left on Previous, while the media crossfades. The steps share one grid cell, so the leaving step fades out as the next fades in. Classes alone run it: `data-starting-style` and `data-ending-style` set where a step starts and ends, and `data-direction` picks the side. With reduced motion, the text fades without sliding.

```tsx
import { Button } from "@/components/ui/button";
import {
  Onboarding,
  OnboardingContent,
  OnboardingDescription,
  OnboardingFooter,
  OnboardingHeader,
  OnboardingIndicator,
  OnboardingMedia,
  OnboardingNav,
  OnboardingNextButton,
  OnboardingPrevButton,
  OnboardingStep,
  OnboardingTitle,
} from "@/components/ui/onboarding";
import { useState } from "react";

const STEPS = [
  { media: "bg-black/15 dark:bg-white/15", step: 1 },
  { media: "bg-black/35 dark:bg-white/35", step: 2 },
  { media: "bg-black/55 dark:bg-white/55", step: 3 },
].map(({ media, step }) => ({
  description: `A short description of step ${step}.`,
  media,
  title: `Step ${step}`,
}));

export const OnboardingTransition = () => {
  const [completed, setCompleted] = useState(false);

  if (completed) {
    return (
      <div className="flex min-h-76.5 flex-col items-center justify-center gap-4">
        <p>Onboarding complete</p>
        <Button onClick={() => setCompleted(false)} variant="outline">
          Restart
        </Button>
      </div>
    );
  }

  return (
    <Onboarding
      className="w-full max-w-sm"
      onComplete={() => setCompleted(true)}
      totalSteps={STEPS.length}
    >
      <OnboardingContent className="grid overflow-hidden">
        {STEPS.map(({ description, media, title }, index) => (
          <OnboardingStep
            className="group col-start-1 row-start-1"
            key={title}
            value={index + 1}
          >
            <OnboardingMedia className="transition-opacity duration-250 group-data-ending-style:opacity-0 group-data-starting-style:opacity-0">
              <div aria-hidden className={`h-40 w-full rounded-xl ${media}`} />
            </OnboardingMedia>
            <OnboardingHeader className="transition duration-300 group-data-ending-style:opacity-0 group-data-starting-style:opacity-0 motion-safe:group-data-[direction=next]:group-data-ending-style:-translate-x-full motion-safe:group-data-[direction=next]:group-data-starting-style:translate-x-full motion-safe:group-data-[direction=previous]:group-data-ending-style:translate-x-full motion-safe:group-data-[direction=previous]:group-data-starting-style:-translate-x-full">
              <OnboardingTitle>{title}</OnboardingTitle>
              <OnboardingDescription>{description}</OnboardingDescription>
            </OnboardingHeader>
          </OnboardingStep>
        ))}
      </OnboardingContent>
      <OnboardingFooter>
        <OnboardingIndicator />
        <OnboardingNav>
          <OnboardingPrevButton />
          <OnboardingNextButton />
        </OnboardingNav>
      </OnboardingFooter>
    </Onboarding>
  );
};
```

## Accessibility

`OnboardingIndicator` is one `progressbar` read as “Step 2 of 3”; its segments are hidden from assistive tech, so nobody hears one bar per step.

Focus stays on the button that changed the step, and the new step is not announced. When a step carries content people must hear, move focus to its `OnboardingTitle`, given `tabIndex={-1}`, once the step renders.

A step you leave is `inert` while it animates out, so focus and screen readers reach only the current step.

Meets WCAG 2.2: [2.4.3 Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) · [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html)

## API reference

### Onboarding

Holds the current step. After the first move, `data-direction` reads `next` or `previous`.

Takes every prop of `div`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `number` | `1` | The step to start on, counted from 1. |
| `onComplete` | `() => void` | — | Called when `OnboardingNextButton` is pressed on the last step. |
| `totalSteps` | `number` | — | How many steps there are. Known on the first render, so the server draws the right indicator and button. |

### OnboardingContent

Wraps the steps.

Takes every prop of `div`.

### OnboardingStep

One step, rendered while it is the current one. The step you leave stays, with `data-ending-style`, until its transitions and animations end; the step you reach has `data-starting-style` for one frame, or turns back from where it is if it was still leaving. Both carry `data-direction`, `next` or `previous`. With no transition, the steps swap at once.

Takes every prop of `div`.

| Prop | Type | Description |
| --- | --- | --- |
| `value` | `number` | Its position, counted from 1. |

### OnboardingMedia

Centers an image or illustration and takes the height of what you put in it.

Takes every prop of `div`.

### OnboardingHeader

Groups the title and the description under the media.

Takes every prop of `div`.

### OnboardingTitle

The step’s title.

Takes every prop of `h2`.

### OnboardingDescription

The step’s description.

Takes every prop of `p`.

### OnboardingFooter

Stacks the indicator over the nav.

Takes every prop of `div`.

### OnboardingIndicator

One `progressbar` announced as “Step 2 of 3”, drawn as one shadcn `Progress` per step, hidden from assistive tech. Its name defaults to “Onboarding progress”; pass `aria-label` to change it.

Takes every prop of `div`.

### OnboardingNav

Spreads the previous and next buttons apart.

Takes every prop of `div`.

### OnboardingPrevButton

Ghost button to the previous step, disabled on the first. It stays focusable while disabled, so focus is not lost when it brings the flow back to step 1. An `onClick` runs before the step changes.

Takes every prop of [shadcn Button](https://ui.shadcn.com/docs/components/base/button).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | `"Previous"` | The label. |

### OnboardingNextButton

Moves forward, or calls `onComplete` on the last step. An `onClick` runs before the step changes.

Takes every prop of [shadcn Button](https://ui.shadcn.com/docs/components/base/button).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `ReactNode` | `"Continue"` | The label. |
| `lastLabel` | `ReactNode` | `"Got it"` | The label on the last step. |

### useOnboarding()

Reads and drives the flow from any descendant of `Onboarding`. `setStep` throws outside 1 to `totalSteps`.

| Return value | Type | Description |
| --- | --- | --- |
| `currentStep, totalSteps` | `number` | The current step and the step count. |
| `canGoToNextStep, canGoToPrevStep` | `boolean` | Whether a step exists after or before the current one. |
| `goToNextStep, goToPrevStep, reset` | `() => void` | Move one step, or back to the first. |
| `setStep` | `Dispatch<SetStateAction<number>>` | Jump to a step. |

[All components](https://alexandrejoly.me/components.md)
