Onboarding Card with Sliding Blur Step Transitions

A four-step setup card for a shop till app. Continue slides the current panel out to the left with a soft blur while the next comes in from the right, and Back reverses the direction. The card's height follows the content on a spring, so a short step and a tall one never cause a jump. A segmented bar at the top fills as the visitor moves forward. The steps are real: a name field, a look picker with a live receipt preview, opening day toggles and a summary. On phones the card fills the width and the day buttons stay in one row.

Code

// Adapted from Motion Primitives (https://motion-primitives.com/docs/transition-panel)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";

const looks = [
  { id: "clay", label: "Clay", swatch: ["#fecdd3", "#9f1239"] },
  { id: "moss", label: "Moss", swatch: ["#d9f99d", "#3f6212"] },
  { id: "ink", label: "Ink", swatch: ["#e2e8f0", "#0f172a"] },
];
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const steps = [
  { title: "Name your shop", hint: "This is what customers see on receipts and emails." },
  { title: "Pick a look", hint: "You can change colours any time from settings." },
  { title: "When are you open?", hint: "Tap the days you take orders." },
  { title: "You are ready to sell", hint: "Here is what we set up for you." },
];

/** Shows one child at a time. The outgoing panel slides and blurs away in the direction of travel. */
function TransitionPanel({ activeIndex, direction, children }: { activeIndex: number; direction: number; children: ReactNode[] }) {
  const reduceMotion = useReducedMotion();
  const innerRef = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState<number | "auto">("auto");

  // Follow the height of whichever panel is showing, so the card resizes with a spring.
  useEffect(() => {
    const node = innerRef.current;
    if (!node) return;
    const observer = new ResizeObserver(() => setHeight(node.offsetHeight));
    observer.observe(node);
    return () => observer.disconnect();
  }, []);

  const variants = {
    enter: (dir: number) => (reduceMotion ? { opacity: 0 } : { x: dir > 0 ? 64 : -64, opacity: 0, filter: "blur(6px)" }),
    center: reduceMotion ? { opacity: 1 } : { x: 0, opacity: 1, filter: "blur(0px)" },
    exit: (dir: number) => (reduceMotion ? { opacity: 0 } : { x: dir > 0 ? -64 : 64, opacity: 0, filter: "blur(6px)" }),
  };

  return (
    <motion.div className="relative overflow-hidden" initial={false} animate={{ height }} transition={{ type: "spring", bounce: 0.15, duration: reduceMotion ? 0 : 0.5 }}>
      <div ref={innerRef} className="relative">
        <AnimatePresence initial={false} mode="popLayout" custom={direction}>
          <motion.div
            key={activeIndex}
            custom={direction}
            variants={variants}
            initial="enter"
            animate="center"
            exit="exit"
            transition={{ x: { type: "spring", stiffness: 260, damping: 28 }, opacity: { duration: 0.2 }, filter: { duration: 0.25 } }}
          >
            {children[activeIndex]}
          </motion.div>
        </AnimatePresence>
      </div>
    </motion.div>
  );
}

export default function OnboardingCardWithSlidingBlurStepTransitions() {
  const reduceMotion = useReducedMotion();
  const [step, setStep] = useState(0);
  const [direction, setDirection] = useState(1);
  const [name, setName] = useState("Bramble and Rye");
  const [look, setLook] = useState("clay");
  const [open, setOpen] = useState(["Tue", "Wed", "Thu", "Fri", "Sat"]);
  const touched = useRef(false);

  const go = (next: number) => {
    const target = Math.min(steps.length - 1, Math.max(0, next));
    setDirection(target > step ? 1 : -1);
    setStep(target);
  };
  const user = () => {
    touched.current = true;
  };

  // Demo: walk forward through the steps, then start again, until someone interacts.
  useEffect(() => {
    if (reduceMotion) return;
    const timer = setTimeout(() => {
      if (touched.current || document.hidden) return;
      if (step === steps.length - 1) {
        setDirection(-1);
        setStep(0);
      } else go(step + 1);
    }, step === 0 ? 2200 : 2600);
    return () => clearTimeout(timer);
  }, [step, reduceMotion]);

  const lookNow = looks.find((item) => item.id === look) ?? looks[0];

  return (
    <section className="bg-rose-50 px-5 py-14 text-stone-900 sm:px-8 sm:py-20" onPointerDown={user} onKeyDown={user}>
      <div className="mx-auto max-w-md">
        <p className="text-center text-xs font-semibold uppercase tracking-widest text-rose-700">Tillpoint setup, 2 minutes</p>

        <div className="mt-5 rounded-3xl bg-white p-6 shadow-xl shadow-rose-900/10 ring-1 ring-stone-900/5 sm:p-8">
          {/* Progress */}
          <ol className="flex gap-1.5" aria-label="Setup progress">
            {steps.map((item, index) => (
              <li key={item.title} className="h-1.5 flex-1 overflow-hidden rounded-full bg-stone-200" aria-current={index === step ? "step" : undefined}>
                <span className="sr-only">
                  Step {index + 1}: {item.title}
                </span>
                <motion.span className="block h-full origin-left rounded-full bg-rose-600" initial={false} animate={{ scaleX: index <= step ? 1 : 0 }} transition={{ type: "spring", stiffness: 200, damping: 28 }} />
              </li>
            ))}
          </ol>

          <div className="mt-6" aria-live="polite">
            <TransitionPanel activeIndex={step} direction={direction}>
              {[
                <div key="name">
                  <h2 className="text-2xl font-bold tracking-tight">{steps[0].title}</h2>
                  <p className="mt-1 text-sm text-stone-600">{steps[0].hint}</p>
                  <label htmlFor="onboarding-slide-name" className="mt-5 block text-sm font-semibold">
                    Shop name
                  </label>
                  <input
                    id="onboarding-slide-name"
                    value={name}
                    onChange={(event) => setName(event.target.value)}
                    className="mt-2 h-12 w-full rounded-xl border border-stone-300 px-4 text-base outline-none focus-visible:border-rose-600 focus-visible:ring-2 focus-visible:ring-rose-200"
                  />
                </div>,
                <div key="look">
                  <h2 className="text-2xl font-bold tracking-tight">{steps[1].title}</h2>
                  <p className="mt-1 text-sm text-stone-600">{steps[1].hint}</p>
                  <div role="radiogroup" aria-label="Shop look" className="mt-5 grid grid-cols-3 gap-3">
                    {looks.map((item) => (
                      <button
                        key={item.id}
                        type="button"
                        role="radio"
                        aria-checked={look === item.id}
                        onClick={() => setLook(item.id)}
                        className={`rounded-2xl border-2 p-2 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 ${look === item.id ? "border-rose-600" : "border-stone-200 hover:border-stone-400"}`}
                      >
                        <span className="flex h-20 items-end gap-1 rounded-xl p-2" style={{ background: item.swatch[0] }}>
                          <span className="h-8 w-1/2 rounded-md" style={{ background: item.swatch[1] }} />
                          <span className="h-4 w-1/3 rounded-md bg-white/80" />
                        </span>
                        <span className="mt-2 block px-1 text-sm font-semibold">{item.label}</span>
                      </button>
                    ))}
                  </div>
                  <div className="mt-4 rounded-2xl p-4" style={{ background: lookNow.swatch[0] }}>
                    <p className="text-xs font-semibold uppercase tracking-widest" style={{ color: lookNow.swatch[1] }}>
                      Receipt preview
                    </p>
                    <p className="mt-1 text-lg font-bold" style={{ color: lookNow.swatch[1] }}>
                      {name || "Your shop"}
                    </p>
                  </div>
                </div>,
                <div key="hours">
                  <h2 className="text-2xl font-bold tracking-tight">{steps[2].title}</h2>
                  <p className="mt-1 text-sm text-stone-600">{steps[2].hint}</p>
                  <div className="mt-5 grid grid-cols-7 gap-1.5">
                    {days.map((day) => {
                      const on = open.includes(day);
                      return (
                        <button
                          key={day}
                          type="button"
                          aria-pressed={on}
                          onClick={() => setOpen((list) => (on ? list.filter((item) => item !== day) : [...list, day]))}
                          className={`h-12 rounded-xl text-xs font-semibold focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-1 ${on ? "bg-rose-600 text-white" : "bg-stone-100 text-stone-700 hover:bg-stone-200"}`}
                        >
                          {day}
                        </button>
                      );
                    })}
                  </div>
                </div>,
                <div key="done">
                  <span className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-600 text-white">
                    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-6 w-6">
                      <path d="m4.5 10.5 3.5 3.5 7.5-8" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
                    </svg>
                  </span>
                  <h2 className="mt-4 text-2xl font-bold tracking-tight">{steps[3].title}</h2>
                  <p className="mt-1 text-sm text-stone-600">{steps[3].hint}</p>
                  <dl className="mt-5 divide-y divide-stone-200 rounded-2xl border border-stone-200 text-sm">
                    <div className="flex justify-between gap-4 px-4 py-3">
                      <dt className="text-stone-600">Shop</dt>
                      <dd className="truncate font-semibold">{name || "Your shop"}</dd>
                    </div>
                    <div className="flex justify-between gap-4 px-4 py-3">
                      <dt className="text-stone-600">Look</dt>
                      <dd className="font-semibold">{lookNow.label}</dd>
                    </div>
                    <div className="flex justify-between gap-4 px-4 py-3">
                      <dt className="text-stone-600">Open</dt>
                      <dd className="font-semibold">{open.length} days a week</dd>
                    </div>
                    <div className="flex justify-between gap-4 px-4 py-3">
                      <dt className="text-stone-600">Card fee</dt>
                      <dd className="font-semibold">1.4% per sale</dd>
                    </div>
                  </dl>
                </div>,
              ]}
            </TransitionPanel>
          </div>

          <div className="mt-7 flex items-center justify-between gap-3">
            <button
              type="button"
              onClick={() => go(step - 1)}
              disabled={step === 0}
              className="h-11 rounded-full px-4 text-sm font-semibold text-stone-700 hover:bg-stone-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 disabled:opacity-40 disabled:hover:bg-transparent"
            >
              Back
            </button>
            <p className="font-mono text-xs text-stone-600">
              {step + 1} / {steps.length}
            </p>
            <button
              type="button"
              onClick={() => (step === steps.length - 1 ? go(0) : go(step + 1))}
              className="h-11 rounded-full bg-stone-900 px-6 text-sm font-semibold text-white hover:bg-stone-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 focus-visible:ring-offset-2"
            >
              {step === steps.length - 1 ? "Start again" : "Continue"}
            </button>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/onboarding-card-with-sliding-blur-step-transitions.json

When to use it

How to customize it

Accessibility

Questions

How do the panels know which way to slide?

The component stores a `direction` of 1 or -1 when the step changes and passes it to framer-motion as `custom`. The `enter` and `exit` variants are functions of that value, so the same panel can leave to the left or to the right.

Why pass custom to AnimatePresence as well?

A panel that is leaving has already been removed from the React tree, so it cannot receive new props. `AnimatePresence` hands it the latest `custom` value, which keeps the exit direction correct when the visitor goes back.

How does the card height animate?

A ResizeObserver measures the inner wrapper whenever the content changes, and the outer wrapper animates its `height` to that number. With `mode="popLayout"` the leaving panel is taken out of the flow, so the measurement is the new panel's height.

Is state kept when I go back a step?

Yes. The values live in the parent component, not in the panels, so the name, look and days are still there when a panel mounts again.

How do I validate before moving on?

Check the current step's values at the top of the Continue handler and return early with an error message if they fail. Call `go(step + 1)` only when they pass.

Adapted from Transition Panel in Motion Primitives, used under its open-source licence. Licences and credits

Related: Multi-Step Signup Form with Step Indicator, Underline Tabs with Icons and Three Panels, Numbered How It Works Steps in a Row.

More tabs and navigation or all free UI components. Start for free to have a full site built around it.

More in Navigation

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap