All patterns

Onboarding Step Transition

The next panel enters from the right as the current one leaves left, with the progress bar advancing behind them.

onboardingcalmpremiuminteraction · finite · intermediate · ~0.5s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

292 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Onboarding Step Transition
 *
 * The next panel slides in from the right while the current one leaves
 * to the left, and the progress bar advances underneath them.
 *
 * Self-contained: depends only on `react` and `motion`. Works with zero
 * props; tune via `variant`, `steps`, `accent`. Sample steps are
 * embedded so the file runs as-is.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type OnboardingStep = {
  title: string;
  body: string;
};

export type OnboardingStepTransitionProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Your own steps. The embedded sample is used when omitted. */
  steps?: OnboardingStep[];
  /** Label of the primary button on the last step. */
  finishLabel?: string;
  /** Progress bar and primary button color. */
  accent?: string;
  /** Fires with the index the flow just moved to. */
  onStepChange?: (index: number) => void;
  /** Fires when the last step is confirmed. */
  onFinish?: () => void;
};

type VariantConfig = {
  /** How far a panel travels on the way in and out, in px. */
  travel: number;
  panelSpring: { type: "spring"; stiffness: number; damping: number };
  progressSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: panels are mostly text, so every spring here sits at or
// above a 0.8 damping ratio — a heading that rebounds after it lands
// makes the whole flow feel cheap. Variants change how far a panel
// travels and how fast it gets there, never how much it wobbles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Short travel, no settle. For long flows where the user will see
  // this transition eight times in a row.
  subtle: {
    travel: 20,
    panelSpring: { type: "spring", stiffness: 520, damping: 48 },
    progressSpring: { type: "spring", stiffness: 300, damping: 36 },
  },
  // Enough travel to establish direction. The all-purpose setting.
  default: {
    travel: 28,
    panelSpring: { type: "spring", stiffness: 420, damping: 40 },
    progressSpring: { type: "spring", stiffness: 240, damping: 30 },
  },
  // Wider travel and a looser bar — the flow feels like pages turning.
  playful: {
    travel: 36,
    panelSpring: { type: "spring", stiffness: 380, damping: 32 },
    progressSpring: { type: "spring", stiffness: 220, damping: 26 },
  },
};

const SAMPLE_STEPS: OnboardingStep[] = [
  {
    title: "Create your workspace",
    body: "Pick a name and a URL. Both can be changed later from settings, so anything works for now.",
  },
  {
    title: "Invite your team",
    body: "Add teammates by email and they land in the right projects on their first day.",
  },
  {
    title: "Connect your tools",
    body: "Sync calendar, storage and issue tracking so everything shows up in one place.",
  },
];

/** Fixed so the card cannot resize mid-slide — a jumping frame fights
 *  the horizontal movement and makes both read as sloppy. */
const PANEL_HEIGHT = 132;

export default function OnboardingStepTransition({
  variant = "default",
  steps = SAMPLE_STEPS,
  finishLabel = "Get started",
  accent = "#5B5BD6",
  onStepChange,
  onFinish,
}: OnboardingStepTransitionProps) {
  // Direction is stored alongside the index because the exit animation
  // has to know which way the flow was heading, not where it ended up.
  const [[index, direction], setState] = useState<[number, number]>([0, 1]);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const isLast = index === steps.length - 1;

  const go = (nextIndex: number, nextDirection: number) => {
    setState([nextIndex, nextDirection]);
    onStepChange?.(nextIndex);
  };

  const handleNext = () => {
    if (isLast) {
      onFinish?.();
      // The sample loops back to the start so the flow stays replayable;
      // in a real app this is where onboarding hands off.
      go(0, 1);
      return;
    }
    go(index + 1, 1);
  };

  // Reduced motion: the panels still replace each other, they just
  // cross-fade in place instead of travelling.
  const panelVariants = reduceMotion
    ? {
        enter: { opacity: 0 },
        center: {
          opacity: 1,
          transition: { duration: 0.18, ease: "easeOut" as const },
        },
        exit: {
          opacity: 0,
          transition: { duration: 0.12, ease: "easeOut" as const },
        },
      }
    : {
        enter: (dir: number) => ({ x: dir * cfg.travel, opacity: 0 }),
        center: {
          x: 0,
          opacity: 1,
          transition: {
            ...cfg.panelSpring,
            // The fade is shorter than the slide, so the incoming panel
            // is readable before it finishes arriving.
            opacity: { duration: 0.2, ease: "easeOut" as const },
          },
        },
        exit: (dir: number) => ({
          x: -dir * cfg.travel,
          opacity: 0,
          transition: {
            ...cfg.panelSpring,
            opacity: { duration: 0.14, ease: "easeOut" as const },
          },
        }),
      };

  return (
    <div
      style={{
        width: 320,
        padding: 20,
        borderRadius: 18,
        border: "1px solid rgba(127,127,140,0.22)",
        background: "rgba(127,127,140,0.07)",
        overflow: "hidden",
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          fontSize: 11.5,
          fontWeight: 600,
          letterSpacing: 0.2,
          opacity: 0.5,
          marginBottom: 8,
        }}
      >
        <span>Set up</span>
        {/* Plain text swap: a counter that animates its own digits would
            pull attention away from the panel that is actually moving. */}
        <span>{`Step ${index + 1} of ${steps.length}`}</span>
      </div>

      <div
        aria-hidden
        style={{
          position: "relative",
          height: 3,
          borderRadius: 3,
          background: "rgba(127,127,140,0.24)",
          overflow: "hidden",
        }}
      >
        <motion.div
          animate={{ scaleX: (index + 1) / steps.length }}
          initial={{ scaleX: 1 / steps.length }}
          transition={reduceMotion ? { duration: 0 } : cfg.progressSpring}
          style={{
            position: "absolute",
            inset: 0,
            borderRadius: 3,
            background: accent,
            // scaleX from the left edge rather than an animated width:
            // width is a layout property and would relayout every frame.
            transformOrigin: "0% 50%",
          }}
        />
      </div>

      <div
        style={{
          position: "relative",
          height: PANEL_HEIGHT,
          marginTop: 16,
        }}
      >
        <AnimatePresence initial={false} custom={direction}>
          <motion.div
            key={index}
            custom={direction}
            variants={panelVariants}
            initial="enter"
            animate="center"
            exit="exit"
            style={{ position: "absolute", inset: 0 }}
          >
            <div
              style={{
                fontSize: 17,
                fontWeight: 650,
                lineHeight: 1.3,
              }}
            >
              {steps[index].title}
            </div>
            <p
              style={{
                margin: "8px 0 0",
                fontSize: 13.5,
                lineHeight: 1.6,
                opacity: 0.6,
              }}
            >
              {steps[index].body}
            </p>
          </motion.div>
        </AnimatePresence>
      </div>

      <div style={{ display: "flex", gap: 8 }}>
        <button
          type="button"
          onClick={() => go(Math.max(0, index - 1), -1)}
          disabled={index === 0}
          style={{
            padding: "9px 14px",
            fontSize: 13,
            fontWeight: 600,
            fontFamily: "inherit",
            color: "inherit",
            background: "transparent",
            border: "1px solid rgba(127,127,140,0.28)",
            borderRadius: 9,
            opacity: index === 0 ? 0.35 : 1,
            cursor: index === 0 ? "default" : "pointer",
          }}
        >
          Back
        </button>
        <button
          type="button"
          onClick={handleNext}
          style={{
            flex: 1,
            padding: "9px 14px",
            fontSize: 13,
            fontWeight: 600,
            fontFamily: "inherit",
            color: "#ffffff",
            background: accent,
            border: "none",
            borderRadius: 9,
            cursor: "pointer",
          }}
        >
          {isLast ? finishLabel : "Continue"}
        </button>
      </div>
    </div>
  );
}

About this pattern

Paging through a setup flow, where the direction of travel is the message: forward sends the old panel left and brings the new one in from the right, back reverses both. The progress bar advances at the same time, so the user always knows how much is left without reading a counter. The card height is fixed and the panels are absolutely positioned, because a frame that resizes mid-slide fights the horizontal movement. Panels are mostly text, so they translate and fade only — never scale, never rebound.

Onboarding flowMulti-step formSetup wizardPermission sequence

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Set up your workspaceStep 2 of 4
    What should we call it?
    Ridgeline
    Who else is joining?
    3 invited
    Next
    Onboarding flow

    Setup questions paged horizontally with a progress indicator above.

Related patterns