roiui
itemexpandable-card-spreadsource: ok · hit
Expandable Card Spread
A spread of expandable cards with mobile swipe-to-cycle stack view.
npx shadcn add "https://roiui.com/r/expandable-card-spread.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
Files6 · source cached 10/14/2026, 1:42:21 PM
registry/brook/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/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 styles from "./expandable-card-spread.module.css";
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={styles.expandedDescription} 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={`${styles.spreadCard} ${isExpanded ? styles.spreadCardSmall : ""}`}
layoutId={`spread-card-${card.id}`}
style={{
backgroundColor: card.color,
borderRadius: 20,
color: textColor,
zIndex: index,
}}
/>
}
>
<motion.div
className={`${styles.circle} ${isExpanded ? styles.circleSmall : ""}`}
layoutId={`spread-circle-${card.id}`}
/>
<h3
className={`${styles.cardTitle} ${isExpanded ? styles.cardTitleSmall : ""}`}
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={styles.popupPositioner}
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={styles.expandedCard}
layoutCrossfade={false}
layoutId={`spread-card-${card.id}`}
onClick={() => dialogActionsRef.current?.close()}
style={{
borderRadius: 20,
backgroundColor: card.color,
color: textColor,
cursor: "pointer",
}}
/>
}
>
<motion.div
className={styles.circleExpanded}
layoutCrossfade={false}
layoutId={`spread-circle-${card.id}`}
/>
<h2 className={styles.expandedTitle} 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={styles.container}>
<div className={styles.header}>
<h2 className={styles.heading} id="expandable-card-heading">
Expandable Card Spread
</h2>
<p className={styles.subheading}>
Click on any card to expand and explore more details. Each card reveals additional content with smooth
animations.
</p>
</div>
<div className={styles.cardsWrapper}>
<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" }}
>
<div className={`${styles.spreadLayout} ${isExpanded ? styles.spreadLayoutSmall : ""}`} 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/blocks/expandable-card-spread/components/expandable-card-spread.module.css
.container {
width: 100%;
max-width: 900px;
margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 40px;
position: relative;
}
.header {
width: 100%;
text-align: left;
flex-shrink: 0;
}
.cardsWrapper {
display: flex;
align-items: flex-start;
justify-content: center;
width: 100%;
min-height: 400px;
padding-top: 80px;
}
.heading {
font-size: 2.5rem;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
margin: 0 0 12px 0;
color: var(--text-primary);
}
.subheading {
font-size: 1.125rem;
line-height: 1.6;
margin: 0;
color: var(--text-tertiary);
max-width: 600px;
}
.spreadLayout {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
}
.spreadCard {
cursor: pointer;
width: 230px;
height: 290px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
padding: 24px;
border: none;
margin-left: -60px;
}
.spreadCard:first-child {
margin-left: 0;
}
.spreadCard:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
@media (max-width: 1024px) {
.spreadCard {
width: 155px;
height: 195px;
padding: 14px;
margin-left: -55px;
}
.spreadCard:first-child {
margin-left: 0;
}
.circle {
width: 20px;
height: 20px;
margin-bottom: 8px;
}
.cardTitle {
font-size: 1rem;
}
}
/* Centred without transform: the popups portal in here and must stay viewport-fixed. */
.spreadLayoutSmall {
position: fixed;
bottom: 90px;
left: 0;
right: 0;
width: fit-content;
margin-inline: auto;
height: auto;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0;
}
.spreadLayoutSmall .spreadCard {
margin-left: -120px;
}
.spreadLayoutSmall .spreadCard:first-child {
margin-left: 0;
}
.spreadCardSmall {
width: 170px;
height: 210px;
padding: 12px;
}
.cardTitle {
font-size: 1.4rem;
font-weight: 500;
line-height: 1.3;
letter-spacing: -0.01em;
white-space: pre-line;
text-align: left;
}
.cardTitleSmall {
font-size: 0.9rem;
line-height: 1.2;
}
.circle {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.25);
margin-bottom: 12px;
}
.circleSmall {
width: 20px;
height: 20px;
margin-bottom: 6px;
}
.circleExpanded {
width: 140px;
height: 140px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.25);
margin-bottom: 26px;
align-self: center;
}
.popupPositioner {
position: fixed;
inset: 0 0 40px 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.expandedCard {
overflow: hidden;
will-change: transform;
width: 400px;
min-height: 500px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
gap: 12px;
pointer-events: auto;
}
.expandedTitle {
font-size: 36px;
font-weight: 500;
margin: 0;
letter-spacing: -0.01em;
line-height: 1.1;
white-space: pre-line;
text-align: left;
}
.expandedDescription {
font-size: var(--font-size-base, 1rem);
line-height: 1.8;
margin: 0;
opacity: 0.8;
}
.mobileStack {
position: relative;
width: 100%;
max-width: 320px;
height: 420px;
display: flex;
align-items: center;
justify-content: center;
}
.mobileCard {
position: absolute;
width: 300px;
height: 400px;
border-radius: 20px;
padding: 24px;
padding-bottom: 56px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
cursor: grab;
touch-action: pan-y;
user-select: none;
}
.mobileCard:active {
cursor: grabbing;
}
.mobileDescription {
font-size: var(--font-size-sm, 0.875rem);
line-height: 1.6;
margin: 12px 0 0 0;
opacity: 0.85;
}
.swipeHint {
position: absolute;
bottom: 24px;
left: 0;
right: 0;
display: flex;
justify-content: center;
pointer-events: none;
}
.swipeHint span {
font-size: var(--font-size-xs, 0.75rem);
opacity: 0.5;
text-transform: uppercase;
letter-spacing: 0.05em;
}
registry/brook/blocks/expandable-card-spread/components/mobile-stack.tsx
"use client";
import { motion, type PanInfo, useReducedMotion } from "motion/react";
import { useState } from "react";
import styles from "./expandable-card-spread.module.css";
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={styles.container}>
<div className={styles.mobileStack}>
{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={styles.mobileCard}
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={styles.circle} />
<h3 className={styles.cardTitle}>{card.title}</h3>
<p className={styles.mobileDescription}>{card.description}</p>
</motion.div>
);
})}
</div>
</div>
);
}
registry/brook/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;
}