Step Sequence
Numbered steps that take turns, with the media beside them cross-fading.
From the Findit template — see it in context
import { StepSequence } from "@/components/ui/step-sequence";
// Demo photos from Unsplash (free under the Unsplash License), served by this site so no request leaves it.
const steps = [
{
value: "brief",
title: "Tell us what you need",
description: "Rooms, budget, the streets you already like — a short call is enough to start.",
image: "/demo/step-sequence-brief.webp",
alt: "Open living room with a long grey sofa",
},
{
value: "tour",
title: "Tour the shortlist",
description: "We visit five homes at most, always with the paperwork checked beforehand.",
image: "/demo/step-sequence-tour.webp",
alt: "White house with a garden path at dusk",
},
{
value: "keys",
title: "Sign and move in",
description: "We handle the offer, the survey and the notary, and hand you the keys.",
image: "/demo/step-sequence-keys.webp",
alt: "Bright bedroom with morning light on the bed",
},
];
export default function StepSequenceDemo() {
return (
<StepSequence
label="How it works"
className="max-w-3xl"
steps={steps.map((step) => ({
value: step.value,
title: step.title,
description: step.description,
media: <img src={step.image} alt={step.alt} />,
}))}
/>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/step-sequence.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/step-sequence.tsx."use client"; import * as React from "react"; import { motion, useReducedMotion, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; /* * Step Sequence — numbered steps that take turns, with the media beside them cross-fading to the active one * (from the Findit "Process" section). Framer's soft spring drives both: spring, bounce 0, duration 0.4s. * A step stays active for `interval` ms (Framer: 5 s), then the next one takes over; choosing a step makes it * active at once and inactive steps sit at 50%. * * Improvements over Framer, and why: the steps are a real tab list (arrow keys, Home and End, aria-selected) * instead of buttons that only a mouse could reach; the rotation pauses while the pointer is over it, so a step * cannot change mid-sentence; and it does not rotate at all with reduced motion — the timer is not a transform * or size animation, so <MotionConfig reducedMotion="user"> would not stop it. */ const soft: Transition = { type: "spring", bounce: 0, duration: 0.4 }; /* Framer dims an inactive step to 50%, which drops its text to 2.1-3.7:1 in every theme; 70% still reads as inactive and passes 4.5:1 (axe audit of the blocks, owner 2026-10-01). */ const DIMMED = 0.7; export type Step = { value: string; title: string; description?: React.ReactNode; /** Shown beside the steps while this one is active: an image, a video, anything. */ media?: React.ReactNode; }; export type StepSequenceProps = { steps: Step[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; /** Accessible name of the sequence. */ label: string; /** Milliseconds a step stays active; 0 turns the rotation off. */ interval?: number; className?: string; }; export function StepSequence({ steps, value, defaultValue, onValueChange, label, interval = 5000, className, }: StepSequenceProps) { const [internal, setInternal] = React.useState(defaultValue ?? steps[0]?.value); const active = value ?? internal; const index = Math.max(0, steps.findIndex((step) => step.value === active)); const [paused, setPaused] = React.useState(false); const reduce = useReducedMotion(); const id = React.useId(); const tabs = React.useRef<(HTMLButtonElement | null)[]>([]); const select = React.useCallback( (next: string) => { if (value === undefined) setInternal(next); onValueChange?.(next); }, [value, onValueChange], ); React.useEffect(() => { if (!interval || paused || reduce || steps.length < 2) return; const timer = window.setTimeout(() => select(steps[(index + 1) % steps.length].value), interval); return () => window.clearTimeout(timer); }, [index, interval, paused, reduce, steps, select]); function onKeyDown(event: React.KeyboardEvent) { const last = steps.length - 1; let next: number | undefined; if (event.key === "ArrowDown" || event.key === "ArrowRight") next = index === last ? 0 : index + 1; else if (event.key === "ArrowUp" || event.key === "ArrowLeft") next = index === 0 ? last : index - 1; else if (event.key === "Home") next = 0; else if (event.key === "End") next = last; if (next === undefined) return; event.preventDefault(); select(steps[next].value); tabs.current[next]?.focus(); } return ( <div className={cn("flex w-full flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-12", className)} onPointerEnter={(event) => event.pointerType === "mouse" && setPaused(true)} onPointerLeave={() => setPaused(false)} onFocusCapture={() => setPaused(true)} onBlurCapture={() => setPaused(false)} > {/* A tab list takes tabs as its own children: a list with <li> wrappers breaks that (axe, 2026-10-01). */} <div role="tablist" aria-label={label} aria-orientation="vertical" className="flex flex-col gap-2 lg:w-2/5 lg:shrink-0"> {steps.map((step, i) => { const selected = i === index; return ( <motion.button key={step.value} ref={(element) => { tabs.current[i] = element; }} type="button" role="tab" id={`${id}-tab-${i}`} aria-selected={selected} aria-controls={`${id}-panel`} tabIndex={selected ? 0 : -1} onClick={() => select(step.value)} onKeyDown={onKeyDown} initial={false} animate={{ opacity: selected ? 1 : DIMMED }} transition={soft} className="flex w-full cursor-pointer gap-4 rounded-md p-2 text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > <span className="flex w-8 shrink-0 flex-col items-center"> <span className="text-sm font-semibold tabular-nums">{String(i + 1).padStart(2, "0")}.</span> {i < steps.length - 1 && <span aria-hidden className="mt-2 w-0.5 flex-1 rounded-full bg-border" />} </span> <span className="flex flex-1 flex-col gap-1.5 pb-4"> <span className="font-medium">{step.title}</span> {step.description && ( /* Inside a dimmed step the row's 70% multiplies whatever colour this is, and muted-foreground would land at 2.7:1 — so an inactive step carries its own, darker secondary tone (0.7 x 0.95 still passes 4.5:1 in the warmest themes, where /85 came up short). */ <span className={cn("text-sm", selected ? "text-muted-foreground" : "text-foreground/95")}> {step.description} </span> )} </span> </motion.button> ); })} </div> <div role="tabpanel" id={`${id}-panel`} aria-labelledby={`${id}-tab-${index}`} className="relative h-64 overflow-hidden rounded-lg bg-muted sm:h-80 lg:h-auto lg:flex-1" > {steps.map((step, i) => ( <motion.div key={step.value} initial={false} animate={{ opacity: i === index ? 1 : 0 }} transition={soft} aria-hidden={i !== index} className="absolute inset-0 *:size-full *:object-cover" > {step.media} </motion.div> ))} </div> </div> ); }
Usage
import { StepSequence } from "@/components/ui/step-sequence";
<StepSequence
label="How it works"
steps={[
{ value: "brief", title: "Tell us what you need", description: "…", media: <Image src={brief} alt="" fill /> },
{ value: "tour", title: "Tour the shortlist", description: "…", media: <Image src={tour} alt="" fill /> },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | { value, title, description?, media? }[] | — | The steps, in order. `media` fills the panel beside them. |
| label | string | — | Accessible name of the sequence. |
| value | string | — | Active step (controlled). |
| defaultValue | string | first step | Step that starts active (uncontrolled). |
| onValueChange | (value: string) => void | — | Called when another step becomes active. |
| interval | number | 5000 | Milliseconds a step stays active; 0 turns the rotation off. |
- The steps are a tab list: arrow keys, Home and End move between them, and the media is their panel. The tabs are the list's own children — wrapping them in <li> breaks the pattern.
- An inactive step sits at 70%, not Framer's 50%, which dropped its text to 2.1-3.7:1 in every theme. Its description carries its own darker tone, because the row's 70% multiplies whatever colour it has.
- The rotation pauses while the pointer is over it or something inside has focus, so a step cannot change mid-sentence.
- With reduced motion it does not rotate at all — a timer is neither a transform nor a size animation, so MotionConfig would not stop it.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.
Create a React + Tailwind CSS + Motion (motion/react) component called StepSequence.
- Two columns from 1024px up (stacked below): on the left numbered steps ("01." in a 32px column with a 2px rule
running to the next step, title font-medium, description text-sm text-muted-foreground); on the right a rounded-lg
panel holding one media element per step, absolutely stacked.
- The active step is at opacity 1, the others at 0.5; the active media is at opacity 1, the others at 0.
Both animate with a spring (bounce 0, duration 0.4s).
- Every `interval` ms (default 5000) the next step becomes active, wrapping around; clicking a step activates it at once.
- Controlled (value + onValueChange) or uncontrolled (defaultValue, first step by default).
- Accessibility: role tablist with aria-orientation vertical, each step a role tab with aria-selected and a roving
tabIndex, arrow keys / Home / End move and select, the media panel is role tabpanel with aria-labelledby.
- Pause the rotation while the pointer is over the component or focus is inside it, and do not rotate at all when
useReducedMotion() is true.












