Stacked Steps
Installation
npx shadcn@latest add https://uibrik.com/r/stacked-steps.jsonInstall the packages
npm install @tabler/icons-react motionAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/stacked-steps/stacked-steps.tsx"use client"; import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react"; import { useAnimate, useReducedMotion } from "motion/react"; import { IconAdjustments, IconAdjustmentsFilled, IconCircleCheck, IconCircleCheckFilled, IconPuzzle, IconPuzzleFilled, } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { Duotone } from "../_cards/duotone"; // Stacked steps. A small deck of "how it works" cards: the current step in // front, the others tucked behind it, their edges peeking out above. Every // few seconds the front card flies out, tucks behind the deck and slides back // in at the back as the rest move forward, round and round. Each step has its // own way out: left, right, up, down, then round again. Pointing at or focusing // the deck holds it, and pointing at it also fans the cards out one way only, // each one further round from the right towards the top, like a hand of cards, // so every step can be seen; clicking the front card, or the arrow keys, turns // it by hand. // // Each card animates itself when its depth changes. A card that wraps round // the deck (front to back, or back to front) leaves in its own direction until // it clears the deck, changes layer while nothing covers it, then slides into // its new place. Nothing ever turns see-through, so two cards never show through each other. export { Duotone }; export type Step = { title: string; description: string; /** Shown in a tinted tile. A <Duotone /> fits best. */ icon: ReactNode; /** The icon and its tile's tint. */ color?: string; }; const STEPS: Step[] = [ { title: "Connect your stack", description: "Link the repo and the tools you already use. Setup takes about a minute.", icon: <Duotone icon={IconPuzzle} fill={IconPuzzleFilled} />, color: "#7c3aed", }, { title: "Tune what matters", description: "Pick the checks you care about. Sensible defaults are already on.", icon: <Duotone icon={IconAdjustments} fill={IconAdjustmentsFilled} />, color: "#ea580c", }, { title: "Ship with confidence", description: "Every pull request gets a clear verdict in under a minute.", icon: <Duotone icon={IconCircleCheck} fill={IconCircleCheckFilled} />, color: "#059669", }, ]; export type StackedStepsProps = { steps?: Step[]; /** Names the deck for screen readers. */ label?: string; /** Turn the deck on its own. Holds while pointed at or focused, and with reduced motion. */ autoplay?: boolean; /** How long each step stays in front while playing, ms. */ duration?: number; /** How much of each card behind shows above the one in front, px. */ peek?: number; /** How much smaller each card behind is than the one in front of it. */ shrink?: number; onStepChange?: (step: number) => void; className?: string; }; const SETTLE = { type: "spring", duration: 0.55, bounce: 0 } as const; const OUT = 0.3; // s, a card sliding clear of the deck const FAN = 8; // deg, how far each card behind swings further than the one in front of it // The way each step leaves the deck, by step index, round and round. const EXITS = [ { x: -1, y: 0 }, // left { x: 1, y: 0 }, // right { x: 0, y: -1 }, // up { x: 0, y: 1 }, // down ] as const; export function StackedSteps({ steps = STEPS, label = "How it works", autoplay = true, duration = 3500, peek = 14, shrink = 0.06, onStepChange, className, }: StackedStepsProps) { const reduce = useReducedMotion(); const n = steps.length; const [{ active, dir }, setState] = useState({ active: 0, dir: 1 as 1 | -1 }); const [held, setHeld] = useState(false); const [hovered, setHovered] = useState(false); const playing = autoplay && !held && !reduce && n > 1; const go = (next: number, d: 1 | -1) => { const to = ((next % n) + n) % n; if (to === active) return; setState({ active: to, dir: d }); onStepChange?.(to); }; // One timer per step, restarted whenever the step changes or the deck is let go. const goRef = useRef(go); useEffect(() => { goRef.current = go; }); useEffect(() => { if (!playing) return; const t = setTimeout(() => goRef.current(active + 1, 1), duration); return () => clearTimeout(t); }, [playing, active, duration]); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowRight" || e.key === "ArrowDown") go(active + 1, 1); else if (e.key === "ArrowLeft" || e.key === "ArrowUp") go(active - 1, -1); else return; e.preventDefault(); }; const stack = peek * (n - 1); return ( <div role="group" aria-roledescription="carousel" aria-label={label} tabIndex={0} className={cn( "relative w-full max-w-90 rounded-3xl outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-zinc-400 dark:focus-visible:outline-zinc-500", className, )} style={{ paddingTop: stack }} onPointerEnter={(e) => { setHeld(true); if (e.pointerType === "mouse") setHovered(true); }} onPointerLeave={() => { setHeld(false); setHovered(false); }} onFocus={() => setHeld(true)} onBlur={() => setHeld(false)} onKeyDown={onKeyDown} > {/* The deck is as tall as its tallest step: invisible copies of every card's content share one grid cell and set its height, and the real cards fill that cell. */} <div className="relative grid"> {steps.map((step, i) => ( <div key={i} aria-hidden className="invisible col-start-1 row-start-1 p-6"> <Body step={step} /> </div> ))} {steps.map((step, i) => ( <Card key={i} step={step} index={i} total={n} depth={(i - active + n) % n} dir={dir} spread={hovered && !reduce} peek={peek} shrink={shrink} onNext={() => go(active + 1, 1)} /> ))} </div> {/* Quiet while playing, so it doesn't talk over everything every few seconds. */} <p className="sr-only" aria-live={playing ? "off" : "polite"}> Step {active + 1} of {n}: {steps[active]?.title}. {steps[active]?.description} </p> </div> ); } function Card({ step, index, total, depth, dir, spread, peek, shrink, onNext, }: { step: Step; index: number; total: number; depth: number; dir: 1 | -1; /** Fan the cards behind out to the sides. */ spread: boolean; peek: number; shrink: number; onNext: () => void; }) { const reduce = useReducedMotion(); const [scope, animate] = useAnimate<HTMLDivElement>(); const last = useRef<number | null>(null); const run = useRef(0); // Fanned, every card behind swings clockwise about the deck's bottom-right corner, each one // further than the last, so they sweep out from the right towards the top. The card turns about its // top centre, so the offset below moves that pivot to the corner. const pose = (d: number, fan = false, w = 0, h = 0) => { const base = { x: 0, y: -peek * d, scale: 1 - shrink * d, rotate: 0 }; if (!fan || d === 0) return base; const deg = d * FAN; const a = (deg * Math.PI) / 180; const vx = w / 2; const vy = h; const rx = vx * Math.cos(a) - vy * Math.sin(a); const ry = vx * Math.sin(a) + vy * Math.cos(a); return { ...base, x: vx - rx, y: base.y + (vy - ry), rotate: deg }; }; // The first paint, before the effect hands the card to Motion. Kept constant so React never rewrites it. const [first] = useState(() => ({ transform: `translateY(${-peek * depth}px) scale(${1 - shrink * depth})`, zIndex: total - depth, })); useEffect(() => { const el = scope.current; if (!el) return; const from = last.current; last.current = depth; const id = ++run.current; const to = pose(depth, spread, el.offsetWidth, el.offsetHeight); const z = total - depth; if (from === null || reduce) { animate(el, { ...to, opacity: 1, zIndex: z }, { duration: 0 }); return; } // Forward, the front card goes to the back; backward, the back card comes to the front. const wrapped = dir === 1 ? depth > from : depth < from; if (!wrapped) { animate(el, { ...to, opacity: 1, zIndex: z }, { ...SETTLE, zIndex: { duration: 0 } }); return; } (async () => { // Out the way this step always leaves, far enough to clear the deck. const exit = EXITS[index % EXITS.length]; const out = { x: exit.x * el.offsetWidth * 1.08, y: pose(from).y + exit.y * (el.offsetHeight * 1.08 + peek * (total - 1)) + (exit.y ? 0 : 8), rotate: exit.x * 6 || exit.y * 3, scale: 0.97, }; // Going forward it leaves from the front, so it stays on top until it's clear. if (dir === 1) await animate(el, { zIndex: total + 1 }, { duration: 0 }); await animate(el, out, { duration: OUT, ease: [0.32, 0, 0.67, 0] }); if (id !== run.current) return; // Clear of the deck: change layer where it can't be seen, then slide into place. await animate(el, { zIndex: z }, { duration: 0 }); if (id !== run.current) return; animate(el, to, SETTLE); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [depth, spread]); const front = depth === 0; return ( <div ref={scope} aria-hidden={!front} className={cn("absolute inset-0 origin-top will-change-transform", front && "cursor-pointer")} style={first} onClick={front ? onNext : undefined} > <div className="relative flex size-full flex-col overflow-hidden rounded-3xl bg-background p-6 shadow-[0_0_0_1px_rgb(0_0_0/0.06),0_1px_2px_rgb(0_0_0/0.04),0_16px_32px_-16px_rgb(0_0_0/0.16)] dark:bg-muted dark:shadow-[0_0_0_1px_rgb(255_255_255/0.07),inset_0_1px_0_rgb(255_255_255/0.05),0_16px_32px_-16px_rgb(0_0_0/0.6)]" > {/* Always drawn: a card behind only shows its top margin, so the next step is ready the moment it's uncovered. */} <Body step={step} /> {/* Cards further back sit a shade deeper. */} <div aria-hidden className="pointer-events-none absolute inset-0 bg-foreground transition-opacity duration-300" style={{ opacity: depth * 0.025, transitionDelay: `${depth === total - 1 ? OUT : 0}s` }} /> </div> </div> ); } function Body({ step }: { step: Step }) { return ( <div className="flex flex-col" style={{ "--tint": step.color ?? "currentColor" } as CSSProperties}> <span className="grid size-12 place-items-center rounded-2xl bg-[color-mix(in_oklab,var(--tint)_12%,transparent)] text-(--tint) shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--tint)_14%,transparent)]"> {step.icon} </span> <h3 className="mt-5 text-[17px] font-medium tracking-tight text-foreground">{step.title}</h3> <p className="mt-1.5 max-w-72 text-pretty text-sm leading-6 text-muted-foreground">{step.description}</p> </div> ); }
Adds the files to
components/uibrik/stacked-steps and installs @tabler/icons-react and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/stacked-steps.Props
StackedSteps- autoplay
- Type boolean
- true
- Turn the deck on its own. Holds while pointed at or focused, and with reduced motion.
- duration
- Type number
- 3500
- How long each step stays in front while playing.
- peek
- Type number
- 14
- How much of each card behind shows above the one in front.
- shrink
- Type number
- 0.06
- How much smaller each card behind is than the one in front.
- steps
- Type { title: string; description: string; icon: ReactNode; color?: string }[]
- 3 sample steps
- Steps in order. The icon sits in a tile tinted with color; <Duotone icon={…} fill={…} /> pairs a Tabler outline icon with its filled twin.
- label
- Type string
- "How it works"
- Names the deck for screen readers.
- onStepChange
- Type (step: number) => void
- –
- Called with the new step, from 0.
- className
- Type string
- –
- Classes for the deck.