roiui
itemexpandable-card-spread-tailwindsource: ok · refreshed
Expandable Card Spread (Tailwind)
A spread of expandable cards with mobile swipe-to-cycle stack view.
npx shadcn add "https://roiui.com/r/expandable-card-spread-tailwind.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
ui-primitives@base-ui/react@1.5.0
Files5 · source cached 10/8/2026, 9:27:17 AM
registry/brook/tailwind/blocks/expandable-card-spread/page.tsx
"use client";
import { ExpandableCardSpread } from "./expandable-card-spread";
import data from "./data.json";
export default function Page() {
return <ExpandableCardSpread data={data} />;
}
registry/brook/tailwind/blocks/expandable-card-spread/components/expandable-card-spread.tsx
"use client";
import { Dialog } from "@base-ui/react/dialog";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import { Fragment, useRef, useState } from "react";
import { useIsMobile } from "../../../hooks/use-mobile";
import { MobileStack } from "./mobile-stack";
const CARD_LAYOUT_TRANSITION = { type: "spring", duration: 0.65, bounce: 0 } as const;
const OPEN_CARD_Z_INDEX = 100;
type CardData = {
id: number;
title: string;
color: string;
textColor?: string;
description: string;
};
type ExpandableCardSpreadProps = {
data: {
cards: CardData[];
};
};
function CardTitleLines({ card, crossfade = true }: { card: CardData; crossfade?: boolean }) {
const lines = card.title.split("\n");
return lines.map((line, index) => (
<Fragment key={`${card.id}-${index}`}>
{/* Exclude the heading's changing line height from the shared glyph bounds. */}
<motion.span
layoutCrossfade={crossfade}
layoutId={`spread-title-${card.id}-${index}`}
style={{ display: "inline-block", lineHeight: 1 }}
>
{line}
</motion.span>
{index < lines.length - 1 ? "\n" : null}
</Fragment>
));
}
// A plain child of the card, so it scales up with it on the way in and follows it home on the way out. The rise
// rides the card's own spring, so both finish together and the text only travels up: a shorter rise ended early
// and the still-growing card pushed it back down.
const DESCRIPTION_MOTION = {
initial: { opacity: 0, y: 110, filter: "blur(4px)" },
animate: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" },
exit: {
opacity: 0,
y: 16,
scale: 0.9,
filter: "blur(4px)",
transition: { duration: 0.15, ease: "easeIn" },
},
transition: {
y: CARD_LAYOUT_TRANSITION,
opacity: { duration: 0.3, ease: [0.32, 0.72, 0, 1] },
scale: { duration: 0.3, ease: [0.32, 0.72, 0, 1] },
filter: { duration: 0.45, ease: [0.32, 0.72, 0, 1] },
},
} as const;
const DESCRIPTION_REDUCED_MOTION = {
initial: false,
animate: { opacity: 1 },
exit: { opacity: 0, transition: { duration: 0 } },
} as const;
function CardDescription({ reducedMotion, text }: { reducedMotion: boolean | null; text: string }) {
const motionProps = reducedMotion ? DESCRIPTION_REDUCED_MOTION : DESCRIPTION_MOTION;
return (
<motion.p {...motionProps} className="m-0 text-base leading-[1.8] opacity-80" style={{ color: "oklch(0.75 0 0)" }}>
{text}
</motion.p>
);
}
type SpreadCardProps = {
card: CardData;
index: number;
isExpanded: boolean;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
portalContainer: HTMLElement | null;
prefersReducedMotion: boolean | null;
};
function SpreadCard({
card,
index,
isExpanded,
isOpen,
onOpenChange,
portalContainer,
prefersReducedMotion,
}: SpreadCardProps) {
const dialogActionsRef = useRef<Dialog.Root.Actions>(null);
const textColor = card.textColor || "#ffffff";
const layoutTransition = prefersReducedMotion ? { duration: 0 } : CARD_LAYOUT_TRANSITION;
return (
// One layout transition for every shared card element, the exiting popup included.
<MotionConfig transition={{ layout: layoutTransition }}>
<Dialog.Root
actionsRef={dialogActionsRef}
modal={false}
onOpenChange={(open, eventDetails) => {
if (!open) {
// Motion owns the closing phase; hiding the popup early flashes the background.
eventDetails.preventUnmountOnClose();
}
// Pressing another card would close this one on pointerdown and open that one on click: two renders a
// frame apart that send every card toward the spread and back. Leave the switch to the click.
if (eventDetails.reason === "outside-press" && portalContainer?.contains(eventDetails.event.target as Node)) {
eventDetails.cancel();
return;
}
onOpenChange(open);
}}
open={isOpen}
>
<Dialog.Trigger
nativeButton={false}
render={
<motion.div
aria-label={`Expand ${card.title}`}
className={`flex cursor-pointer flex-col items-start justify-end border-none first:ml-0 focus-visible:outline-2 focus-visible:outline-[var(--ring)] focus-visible:outline-offset-2 ${
isExpanded
? "-ml-[120px] h-[210px] w-[170px] p-3"
: "-ml-[55px] lg:-ml-[60px] h-[195px] w-[155px] p-3.5 lg:h-[290px] lg:w-[230px] lg:p-6"
}`}
layoutId={`spread-card-${card.id}`}
style={{
backgroundColor: card.color,
borderRadius: 20,
color: textColor,
zIndex: index,
}}
/>
}
>
<motion.div
className={`rounded-full bg-white/25 ${isExpanded ? "mb-1.5 h-5 w-5" : "mb-2 h-5 w-5 lg:mb-3 lg:h-8 lg:w-8"}`}
layoutId={`spread-circle-${card.id}`}
/>
<h3
className={`whitespace-pre-line text-left font-medium tracking-[-0.01em] ${
isExpanded ? "text-[0.9rem] leading-[1.2]" : "text-[1rem] leading-[1.3] lg:text-[1.4rem]"
}`}
style={{ color: "oklch(0.75 0 0)" }}
>
<CardTitleLines card={card} />
</h3>
</Dialog.Trigger>
<Dialog.Portal container={portalContainer} keepMounted>
<AnimatePresence
onExitComplete={() => {
if (!isOpen) {
dialogActionsRef.current?.unmount();
}
}}
>
{isOpen ? (
// Portaled into the deck and dropped to the card's own zIndex on exit, so the closing
// card stays behind higher cards while it crossfades back into the trigger. A body
// portal paints above every card: the card slid over its neighbours, then sank.
<motion.div
className="pointer-events-none fixed inset-0 bottom-10 flex items-center justify-center"
exit={{ zIndex: index, transition: { duration: 0 } }}
key={`positioner-${card.id}`}
layoutRoot
style={{ zIndex: OPEN_CARD_Z_INDEX }}
>
<Dialog.Popup
aria-label={card.title}
render={
// Open without a crossfade: a half-faded popup ghosts the cards behind it.
<motion.div
className="pointer-events-auto flex min-h-[500px] w-[400px] flex-col items-start justify-end gap-3 overflow-hidden p-6 will-change-transform"
layoutCrossfade={false}
layoutId={`spread-card-${card.id}`}
onClick={() => dialogActionsRef.current?.close()}
style={{
borderRadius: 20,
backgroundColor: card.color,
color: textColor,
cursor: "pointer",
}}
/>
}
>
<motion.div
className="mb-[26px] h-[140px] w-[140px] self-center rounded-full bg-white/25"
layoutCrossfade={false}
layoutId={`spread-circle-${card.id}`}
/>
<h2
className="m-0 whitespace-pre-line text-left font-medium text-4xl leading-[1.1] tracking-[-0.01em]"
style={{ color: "oklch(0.75 0 0)" }}
>
<CardTitleLines card={card} crossfade={false} />
</h2>
<CardDescription reducedMotion={prefersReducedMotion} text={card.description} />
</Dialog.Popup>
</motion.div>
) : null}
</AnimatePresence>
</Dialog.Portal>
</Dialog.Root>
</MotionConfig>
);
}
export function ExpandableCardSpread({ data }: ExpandableCardSpreadProps) {
const [expandedId, setExpandedId] = useState<number | null>(null);
// State, not a ref: the keepMounted portals resolve their container before refs attach.
const [deck, setDeck] = useState<HTMLDivElement | null>(null);
const prefersReducedMotion = useReducedMotion();
const isMobile = useIsMobile(640);
const isExpanded = expandedId !== null;
return (
<section
aria-labelledby="expandable-card-heading"
className="relative mx-auto flex h-full w-full max-w-[900px] flex-col items-center pt-10"
>
<div className="w-full shrink-0 text-left">
<h2
className="m-0 mb-3 font-semibold text-4xl text-[var(--text-primary)] leading-tight tracking-tight"
id="expandable-card-heading"
>
Expandable Card Spread
</h2>
<p className="m-0 max-w-[600px] text-[var(--text-tertiary)] text-lg leading-relaxed">
Click on any card to expand and explore more details. Each card reveals additional content with smooth
animations.
</p>
</div>
<div className="flex min-h-[400px] w-full items-start justify-center pt-20">
<AnimatePresence mode="wait">
{isMobile ? (
<motion.div
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
key="stack"
transition={{ duration: prefersReducedMotion ? 0 : 0.3, ease: "easeOut" }}
>
<MobileStack cards={data.cards} />
</motion.div>
) : (
<motion.div
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.95 }}
key="spread"
transition={{ duration: prefersReducedMotion ? 0 : 0.3, ease: "easeOut" }}
>
{/* Centred without transform: the popups portal in here and must stay viewport-fixed. */}
<div
className={`flex items-center justify-center gap-0 ${
isExpanded ? "fixed inset-x-0 bottom-[90px] mx-auto h-auto w-fit flex-row" : ""
}`}
ref={setDeck}
>
{data.cards.map((card, index) => (
<SpreadCard
card={card}
index={index}
isExpanded={isExpanded}
isOpen={expandedId === card.id}
key={card.id}
onOpenChange={(open) => {
if (open) {
setExpandedId(card.id);
} else {
setExpandedId((current) => {
if (current === card.id) {
return null;
}
return current;
});
}
}}
portalContainer={deck}
prefersReducedMotion={prefersReducedMotion}
/>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}
registry/brook/tailwind/blocks/expandable-card-spread/components/mobile-stack.tsx
"use client";
import { motion, type PanInfo, useReducedMotion } from "motion/react";
import { useState } from "react";
type CardData = {
id: number;
title: string;
color: string;
textColor?: string;
description: string;
};
type MobileStackProps = {
cards: CardData[];
};
const SWIPE_THRESHOLD = 100;
export function MobileStack({ cards }: MobileStackProps) {
const [frontIndex, setFrontIndex] = useState(0);
const prefersReducedMotion = useReducedMotion();
const handleDragEnd = (_: unknown, info: PanInfo) => {
if (Math.abs(info.offset.x) > SWIPE_THRESHOLD) {
setFrontIndex((prev) => (prev + 1) % cards.length);
}
};
return (
<div className="relative mx-auto flex min-h-[600px] w-full max-w-[900px] flex-col items-center justify-center">
<div className="relative flex h-[420px] w-full max-w-[320px] items-center justify-center">
{cards.map((card, i) => {
const stackPosition = (i - frontIndex + cards.length) % cards.length;
const isTopCard = stackPosition === 0;
return (
<motion.div
animate={{
scale: 1 - stackPosition * 0.03,
x: stackPosition * (13 - stackPosition),
y: stackPosition * 6,
rotate: Math.sqrt(stackPosition) * 3,
}}
className="absolute flex h-[400px] w-[300px] cursor-grab touch-pan-y select-none flex-col items-start justify-end rounded-[20px] p-6 pb-14 active:cursor-grabbing"
drag={isTopCard ? "x" : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={1}
key={card.id}
layout
onDragEnd={isTopCard ? handleDragEnd : undefined}
style={{
backgroundColor: card.color,
color: card.textColor || "#ffffff",
zIndex: cards.length - stackPosition,
cursor: isTopCard ? "grab" : "auto",
}}
transition={
prefersReducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 300, damping: 25 }
}
whileDrag={prefersReducedMotion ? { cursor: "grabbing" } : { cursor: "grabbing", scale: 1.02 }}
>
<div className="mb-3 h-8 w-8 rounded-full bg-white/25" />
<h3 className="whitespace-pre-line text-left font-medium text-[1.4rem] leading-[1.3] tracking-[-0.01em]">
{card.title}
</h3>
<p className="mt-3 text-sm leading-[1.6] opacity-85">{card.description}</p>
</motion.div>
);
})}
</div>
</div>
);
}
registry/brook/tailwind/blocks/expandable-card-spread/data.json
{
"cards": [
{
"id": 1,
"title": "Design\nSystems",
"color": "oklch(0.162 0.000 0.0)",
"textColor": "var(--secondary-foreground)",
"description": "A structured approach to building reusable components and patterns that maintain consistency across products."
},
{
"id": 2,
"title": "Motion\nPrinciples",
"color": "oklch(0.195 0.000 0.0)",
"textColor": "var(--secondary-foreground)",
"description": "Guidelines for meaningful animation that communicates state changes and guides user attention without distraction."
},
{
"id": 3,
"title": "Accessible\nInterfaces",
"color": "oklch(0.254 0.000 0.0)",
"textColor": "var(--secondary-foreground)",
"description": "Techniques for building interfaces that work for everyone, regardless of ability or assistive technology."
},
{
"id": 4,
"title": "Layout\nComposition",
"color": "oklch(0.302 0.000 0.0)",
"textColor": "var(--secondary-foreground)",
"description": "Strategies for arranging elements on screen to create clear visual hierarchy and intuitive navigation."
},
{
"id": 5,
"title": "Type\nScale",
"color": "oklch(0.348 0.000 0.0)",
"textColor": "var(--secondary-foreground)",
"description": "A modular system for sizing and spacing text that creates rhythm and readability across breakpoints."
}
]
}
hooks/use-mobile.ts
"use client";
import { useEffect, useState } from "react";
const DEFAULT_MOBILE_BREAKPOINT = 768;
export function useIsMobile(breakpoint = DEFAULT_MOBILE_BREAKPOINT) {
const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < breakpoint);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < breakpoint);
return () => mql.removeEventListener("change", onChange);
}, [breakpoint]);
return !!isMobile;
}