Card Deck That Spreads into a Row with Toggle

Four small widget cards that live in one pile and deal out on request. In the deck state the cards overlap in the middle with a slight fan. Pressing the toggle throws each card to its own slot with a springy overshoot and a small resting tilt, one after another; pressing again gathers them back in reverse order. The stage grows and shrinks with the layout. On wide screens the cards land in one row of four; on phones they land in a two by two grid, worked out from the measured width.

Code

// Adapted from Animata (https://animata.design/docs/card/card-spread)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { Check, Layers, LayoutGrid } from "lucide-react";

const CARD_W = 184;
const CARD_H = 236;
const GAP = 14;

const packing = [
  { label: "Passports", done: true },
  { label: "Rail passes", done: true },
  { label: "Film camera", done: false },
  { label: "Rain jackets", done: false },
];

const days = [
  { day: "Mon", place: "Porto", note: "Tiles and the river" },
  { day: "Wed", place: "Coimbra", note: "Old library tour" },
  { day: "Fri", place: "Lisbon", note: "Tram 28 at sunrise" },
];

const budget = [
  { label: "Trains", amount: 212, share: 0.55 },
  { label: "Stays", amount: 386, share: 1 },
  { label: "Food", amount: 240, share: 0.62 },
];

function Shell({ title, tone, children }: { title: string; tone: string; children: ReactNode }) {
  return (
    <div className={`flex h-full w-full flex-col rounded-2xl p-3.5 shadow-lg shadow-stone-900/10 ring-1 ring-black/5 ${tone}`}>
      <h3 className="text-xs font-semibold uppercase tracking-wider opacity-70">{title}</h3>
      <div className="mt-3 flex-1 text-sm">{children}</div>
    </div>
  );
}

// The four cards, bottom of the deck first. Each also sets its resting tilt when spread.
const cards: { id: string; tilt: number; lift: number; node: ReactNode }[] = [
  {
    id: "notes",
    tilt: -2,
    lift: 0,
    node: (
      <Shell title="Trip notes" tone="bg-amber-200 text-amber-950">
        <p className="font-medium leading-snug">Ask Ines about the bakery near the station.</p>
        <p className="mt-2 leading-snug opacity-80">Museums close on Mondays. Book the tile workshop before we leave.</p>
      </Shell>
    ),
  },
  {
    id: "packing",
    tilt: 3,
    lift: 8,
    node: (
      <Shell title="Packing list" tone="bg-white text-stone-900">
        <ul className="space-y-2.5">
          {packing.map((item) => (
            <li key={item.label} className="flex items-center gap-2.5">
              <span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${item.done ? "bg-emerald-500 text-white" : "border-2 border-stone-300"}`}>
                {item.done && <Check aria-hidden="true" className="h-3 w-3" strokeWidth={3} />}
              </span>
              <span className={item.done ? "text-stone-500 line-through" : "font-medium"}>{item.label}</span>
            </li>
          ))}
        </ul>
      </Shell>
    ),
  },
  {
    id: "route",
    tilt: -2,
    lift: 0,
    node: (
      <Shell title="Route" tone="bg-sky-600 text-white">
        <ol className="space-y-3">
          {days.map((stop) => (
            <li key={stop.day} className="flex gap-3">
              <span className="w-8 shrink-0 text-xs font-semibold uppercase leading-5 text-sky-100">{stop.day}</span>
              <span>
                <span className="block font-semibold leading-5">{stop.place}</span>
                <span className="block text-xs text-sky-100">{stop.note}</span>
              </span>
            </li>
          ))}
        </ol>
      </Shell>
    ),
  },
  {
    id: "budget",
    tilt: 2,
    lift: 4,
    node: (
      <Shell title="Budget" tone="bg-stone-900 text-stone-50">
        <p className="text-2xl font-semibold tabular-nums">€838</p>
        <p className="text-xs text-stone-300">of €1,100 planned</p>
        <ul className="mt-3 space-y-2">
          {budget.map((row) => (
            <li key={row.label}>
              <div className="flex justify-between text-xs text-stone-200">
                <span>{row.label}</span>
                <span className="tabular-nums">€{row.amount}</span>
              </div>
              <div className="mt-1 h-1.5 rounded-full bg-white/15">
                <div className="h-full rounded-full bg-lime-300" style={{ width: `${row.share * 100}%` }} />
              </div>
            </li>
          ))}
        </ul>
      </Shell>
    ),
  },
];

export default function CardDeckThatSpreads() {
  const reduce = useReducedMotion();
  const stageRef = useRef<HTMLDivElement>(null);
  const [spread, setSpread] = useState(false);
  const [width, setWidth] = useState(900);

  useEffect(() => {
    const stage = stageRef.current;
    if (!stage) return;
    const measure = () => setWidth(stage.offsetWidth);
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(stage);
    return () => ro.disconnect();
  }, []);

  // Deal the cards once shortly after load so the preview shows both states.
  useEffect(() => {
    const timer = setTimeout(() => setSpread(true), reduce ? 0 : 750);
    return () => clearTimeout(timer);
  }, [reduce]);

  const perRow = width >= 4 * CARD_W + 3 * GAP ? 4 : 2;
  // On narrow screens the cards shrink a little so two fit side by side.
  const cardW = perRow === 4 ? CARD_W : Math.min(CARD_W, Math.floor((width - GAP - 16) / 2));
  const rows = spread ? Math.ceil(cards.length / perRow) : 1;
  const stageHeight = rows * CARD_H + (rows - 1) * GAP + 48;

  const place = (i: number) => {
    if (!spread) {
      // Deck: every card sits in the middle, fanned by a few pixels and degrees.
      const fromTop = cards.length - 1 - i;
      return { x: -fromTop * 5, y: -fromTop * 5, rotate: (i - 1.5) * 2.5, scale: 1 - fromTop * 0.02 };
    }
    const col = i % perRow;
    const row = Math.floor(i / perRow);
    return {
      x: (col - (perRow - 1) / 2) * (cardW + GAP),
      y: row * (CARD_H + GAP) + cards[i].lift,
      rotate: cards[i].tilt,
      scale: 1,
    };
  };

  return (
    <section className="overflow-hidden bg-stone-100 px-4 py-16 sm:px-6">
      <div className="mx-auto max-w-4xl text-center">
        <h2 className="text-3xl font-semibold tracking-tight text-stone-900 sm:text-4xl">Everything for the trip, in one stack.</h2>
        <p className="mx-auto mt-3 max-w-xl text-base text-stone-600">
          Notes, lists, routes and money stay together as a tidy deck. Deal them out when you need to see it all at once.
        </p>
        <button
          type="button"
          aria-pressed={spread}
          onClick={() => setSpread((value) => !value)}
          className="mt-6 inline-flex h-11 items-center gap-2 rounded-full bg-stone-900 px-5 text-sm font-semibold text-white transition-colors hover:bg-stone-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-100"
        >
          {spread ? <Layers aria-hidden="true" className="h-4 w-4" /> : <LayoutGrid aria-hidden="true" className="h-4 w-4" />}
          {spread ? "Stack cards" : "Spread cards"}
        </button>

        <motion.div
          ref={stageRef}
          className="relative mx-auto mt-6 w-full"
          initial={false}
          animate={{ height: stageHeight }}
          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 30 }}
        >
          {cards.map((card, i) => (
            <motion.div
              key={card.id}
              className="absolute left-1/2 top-6 text-left"
              style={{ width: cardW, height: CARD_H, marginLeft: -cardW / 2, zIndex: i + 1, transformOrigin: "50% 100%" }}
              initial={false}
              animate={place(i)}
              whileHover={reduce ? undefined : { y: place(i).y - 8 }}
              transition={
                reduce
                  ? { duration: 0 }
                  : spread
                    ? { type: "spring", stiffness: 240, damping: 20, mass: 0.9, delay: i * 0.05 }
                    : { type: "spring", stiffness: 300, damping: 32, delay: (cards.length - 1 - i) * 0.04 }
              }
            >
              {card.node}
            </motion.div>
          ))}
        </motion.div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/card-deck-that-spreads-into-a-row-with-toggle.json

When to use it

How to customize it

Accessibility

Questions

How do the cards know where to land?

Every card is absolutely positioned at the centre of the stage. A small `place` function returns an x and y offset, a rotation and a scale for each card from its index and the current state, and framer-motion animates between the two sets of values.

Why measure the width in JavaScript?

The spread layout is made of pixel offsets from the centre, so the component needs to know whether four cards fit side by side. A `ResizeObserver` reads the stage width and switches between four per row and two per row.

Can I use more than four cards?

Yes. Add entries to `cards`. On wide screens you may want to raise the per-row count in the `perRow` line; extra cards wrap onto new rows and the stage height follows.

Can the cards contain interactive content?

They can hold anything, including links and inputs. Bear in mind that in the deck state only the top card is fully visible, so avoid putting the only route to an action on a lower card.

Does the hover lift work on touch screens?

The lift is a hover extra and is skipped on touch. Nothing depends on it: the toggle button does all the work.

Adapted from Card Spread in Animata, used under its open-source licence. Licences and credits

Related: Fanned Card Stack with Cycle Controls, Draggable Card Stack Carousel with Spring Physics, Expanding Card Grid on Hover and Focus.

More animated cards or all free UI components. Start for free to have a full site built around it.

More in Animated Cards

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap