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

More from roiui

Browse all components in this library →