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