shadimage
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 · medium
animationmotion
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;
}

More from roiui

Browse all components in this library →