Step Cards
A row of cards where the open one takes the free width and the closed ones narrow to their number.
$npx shadcn@latest add https://framebrick.co/r/step-cards.jsonFrom the Ciketo template, see it in context.
Tell us what you need
Rooms, budget, the streets you already like — a short call is enough to start.
Week one
A written brief within two days, and a fixed price before anything starts.

Tour the shortlist
We visit five homes at most, always with the paperwork checked beforehand.
Weeks two and three
Every viewing comes with a one-page summary of what we found.

Sign and move in
We handle the offer, the survey and the notary, and hand you the keys.
Week six
One person from start to finish, whose number you already have.

import { StepCards } from "@/components/ui/step-cards";
// 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.",
label: "Week one",
note: "A written brief within two days, and a fixed price before anything starts.",
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.",
label: "Weeks two and three",
note: "Every viewing comes with a one-page summary of what we found.",
image: "/demo/step-sequence-tour.webp",
alt: "Modern dark-clad house lit up at dusk under a large tree",
},
{
value: "keys",
title: "Sign and move in",
description: "We handle the offer, the survey and the notary, and hand you the keys.",
label: "Week six",
note: "One person from start to finish, whose number you already have.",
image: "/demo/step-sequence-keys.webp",
alt: "Bright open living room with a staircase and a blue sofa",
},
];
export default function StepCardsDemo() {
return (
<StepCards
label="How it works"
className="max-w-3xl"
steps={steps.map((step) => ({
value: step.value,
title: step.title,
description: step.description,
label: step.label,
note: step.note,
media: <img src={step.image} alt={step.alt} />,
}))}
/>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/step-cards.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-cards.tsx."use client"; import * as React from "react"; import { motion, useReducedMotion, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; /* * Step Cards — a row of cards where one is open: the open card takes the free width and its parts spread from top to * bottom, the closed ones narrow to their number (from the Ciketo "How it works" steps). A click on a card or its number * opens another; there is no timer and no hover. Framer's spring: bounce 0, 0.4s, on the width and on the fade. * From 768px up it is a row of fixed height; below that a column, where closed cards keep their number and title. * * Differences from Ciketo, each with its reason: * - closed cards sit at 70% and their text carries its own tone (foreground/95), not Ciketo's 60%: a dimmed card * multiplies its text colour by the opacity, and muted-foreground would land under 3:1; * - the details of a closed card stay in the HTML but are inert (Ciketo removes them, and so the text is gone for * search engines), and the number is a real button with aria-expanded; * - reduced motion is a zero-duration transition, not different markup: the server cannot know it, so markup that * depended on it would fail hydration. * flex-grow is neither a transform nor a size key, so <MotionConfig reducedMotion="user"> would not skip it. */ const spring: Transition = { type: "spring", bounce: 0, duration: 0.4 }; const DIMMED = 0.7; export type StepCard = { value: string; title: React.ReactNode; /** Under the title, in an open card. */ description?: React.ReactNode; /** A small heading over the note, after a rule, at the bottom of an open card. */ label?: React.ReactNode; note?: React.ReactNode; /** The photo at the bottom of an open card, 3:2. */ media?: React.ReactNode; }; export type StepCardsProps = { steps: StepCard[]; value?: string; /** The card that starts open; the first one by default. */ defaultValue?: string; onValueChange?: (value: string) => void; /** Accessible name of the row. */ label: string; /** Height of the row from 768px up, in px. */ height?: number; /** Width of a closed card from 768px up, in px. */ collapsedSize?: number; className?: string; }; /* A part of the card that is there when it is open: it fades, and below 768px it also opens by height (a column has to make room); from 768px up it keeps its height and is only clipped by the narrow card. Closed, it is inert. */ function Reveal({ open, reduce, className, children }: { open: boolean; reduce: boolean; className?: string; children: React.ReactNode }) { return ( <motion.div inert={!open} initial={false} animate={{ opacity: open ? 1 : 0 }} transition={reduce ? { duration: 0 } : spring} className={cn( "grid transition-[grid-template-rows] duration-400 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none", open ? "grid-rows-[1fr]" : "grid-rows-[0fr] md:grid-rows-[1fr]", className, )} > <div className="min-h-0 overflow-hidden">{children}</div> </motion.div> ); } export function StepCards({ steps, value, defaultValue, onValueChange, label, height = 600, collapsedSize = 105, className, }: StepCardsProps) { const [internal, setInternal] = React.useState(defaultValue ?? steps[0]?.value); const active = value ?? internal; const reduce = Boolean(useReducedMotion()); const id = React.useId(); const total = String(steps.length).padStart(2, "0"); function select(next: string) { if (next === active) return; if (value === undefined) setInternal(next); onValueChange?.(next); } return ( <ol aria-label={label} style={{ ["--cards-h" as string]: `${height}px`, ["--cards-collapsed" as string]: `${collapsedSize}px` }} className={cn("flex w-full flex-col gap-2.5 md:h-(--cards-h) md:flex-row", className)} > {steps.map((step, index) => { const open = step.value === active; const buttonId = `${id}-${index}-button`; const descriptionId = `${id}-${index}-description`; const detailsId = `${id}-${index}-details`; /* A dimmed card multiplies its text colour by 70%: muted-foreground would fall to 2.7:1. */ const muted = open ? "text-muted-foreground" : "text-foreground/95"; return ( <motion.li key={step.value} /* The whole closed card is the target; the keyboard reaches the same thing through the number's button, whose click bubbles here. */ onClick={() => select(step.value)} initial={false} animate={{ opacity: open ? 1 : DIMMED, flexGrow: open ? 1 : 0 }} transition={reduce ? { duration: 0 } : spring} className={cn( "flex min-w-0 flex-col gap-5 overflow-hidden rounded-lg border bg-card p-5 text-card-foreground md:basis-(--cards-collapsed) md:justify-between md:gap-0", !open && "cursor-pointer md:shrink-0", )} > <div className="flex flex-col gap-5"> <button id={buttonId} type="button" aria-expanded={open} aria-controls={`${descriptionId} ${detailsId}`} className={cn( "flex items-baseline gap-1.5 rounded-sm text-left whitespace-nowrap tabular-nums outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", open ? "cursor-default" : "cursor-pointer", )} > <span className="text-lg font-semibold">{String(index + 1).padStart(2, "0")}</span> <span className={cn("text-sm", muted)}>/ {total}</span> {/* From 768px a closed card shows only its number, so the button needs the title as its name. */} <span className="sr-only">{step.title}</span> </button> <div className="flex flex-col gap-2.5"> <h3 className={cn("font-medium", !open && "md:hidden")}>{step.title}</h3> {step.description && ( <Reveal open={open} reduce={reduce}> <p id={descriptionId} className={cn("text-sm", muted)}> {step.description} </p> </Reveal> )} </div> </div> {(step.label || step.note || step.media) && ( <Reveal open={open} reduce={reduce}> <div id={detailsId} className="flex flex-col gap-5 pt-1"> {(step.label || step.note) && ( <div className="flex flex-col gap-5"> <div aria-hidden className="h-px bg-border" /> <div className="flex flex-col gap-2.5"> {step.label && <p className="text-xs font-medium">{step.label}</p>} {step.note && <p className={cn("text-sm", muted)}>{step.note}</p>} </div> </div> )} {step.media && ( <div className="relative aspect-[3/2] w-full max-w-[450px] overflow-hidden rounded-md bg-muted [&>*]:size-full [&>*]:object-cover"> {step.media} </div> )} </div> </Reveal> )} </motion.li> ); })} </ol> ); }
Usage
import { StepCards } from "@/components/ui/step-cards";
<StepCards
label="How it works"
steps={steps.map((s) => ({
value: s.slug,
title: s.title,
description: s.text,
label: s.when,
note: s.detail,
media: <Image src={s.photo} alt="" fill />,
}))}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| steps | { value, title, description?, label?, note?, media? }[] | — | The cards. An open card shows the number, title and description at the top, and a rule, a label with a note and a 3:2 photo at the bottom. |
| label | string | — | Accessible name of the row. |
| value | string | — | Open card (controlled). |
| defaultValue | string | first card | Card that starts open (uncontrolled). |
| onValueChange | (value: string) => void | — | Called when another card opens. |
| height | number | 600 | Height of the row from 768 px up, in px. The open card has to fit in it: 600 holds a 3:2 photo 450 px wide with a two-line description and note. |
| collapsedSize | number | 105 | Width of a closed card from 768 px up, in px. Under about 100 the "01 / 03" no longer fits beside the card's padding. |
- One card is always open: a click on a card or its number opens another, and a click on the open one does nothing. No timer, no hover.
- The row has a fixed height, as in Framer, and the open card does not shrink its photo to fit: parts taller than the row are clipped at the bottom. Give shorter content a shorter row, or leave the photo out.
- Below 768 px it is a column, where closed cards keep their number and title and open by height.
- Closed cards sit at 70% and their text carries its own tone: a dimmed card multiplies its text colour by the opacity, and muted-foreground would fall to 2.7:1. Ciketo dims to 60%.
- The details of a closed card stay in the HTML but are inert (Ciketo removes them, so the text is gone for search engines). The number is a real button with aria-expanded, named by the title.
- Reduced motion is a zero-duration transition, not different markup: the server cannot know it, and markup that depended on it would fail hydration. Flex-grow is not a transform, so MotionConfig would not skip 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 StepCards.
- An ol with aria-label. From 768px up a row of fixed height (default 600px) with gap-2.5; below that a column.
- Each card is a motion.li: rounded-lg border bg-card p-5, overflow-hidden, flex column. At the top the number ("01" in
text-lg font-semibold, then "/ 04" in text-sm) as a real button with aria-expanded, then the title (h3 font-medium) and
a description. At the bottom (from 768px, justify-between) a 1px rule, a small label, a note and a 3:2 photo (max 450px).
- Exactly one card is open (the first by default). Clicking a card, or pressing its number button, opens it — put the
click handler on the li and let the keyboard's button click bubble to it. Clicking the open card does nothing.
- The open card animates flex-grow to 1; closed cards to 0 with a fixed flex-basis (default 105px) so they narrow to
their number. Closed cards fade to opacity 0.7 and their text uses text-foreground/95 (the opacity multiplies the colour;
muted-foreground would fall to 2.7:1). Spring: bounce 0, duration 0.4s, for the width and the fade.
- The description, rule, note and photo fade with opacity and are inert when closed. Below 768px they also open by height
with a CSS grid-template-rows 0fr → 1fr transition; from 768px they keep their height and are clipped by the narrow
card. From 768px a closed card shows only its number, so the title is md:hidden and the button gets an sr-only title.
Keep the number on one line (whitespace-nowrap): at 105px a closed card has 63px of room for "01 / 04".
- The row has a fixed height and does not shrink the photo: parts taller than the row are clipped at the bottom.
- Reduced motion: use useReducedMotion() only to switch the transition to zero duration — never to change the markup,
because the server cannot know it and a different render is a hydration mismatch.










