Skip to content
Docs
Card Spread

Card Spread

A stacked deck of widget cards with a hover peek and a throw-on-spread animation — layout-first, CSS-only.

requires interactionclickhover
Loading...

Four related cards share one compact stack until someone asks to see more. Hover the deck and the tiles fan slightly; hit Spread cards and they throw into a row. Good for dashboards, widget galleries, or anywhere you want several w-48 tiles without eating horizontal space up front.

The demo wires up Notes, Shopping List, and a couple of note variants. Swap in your own card components as long as they keep the same footprint.

Installation

CLI

pnpm dlx shadcn@latest add https://animata.design/r/card/card-spread.json

Manual

Run the following command

This creates card-spread.tsx and card-spread.css in components/animata/card.

mkdir -p components/animata/card && touch components/animata/card/card-spread.tsx components/animata/card/card-spread.css

Paste the code

@reference "../../styles/globals.css";
 
@layer components {
  .card-spread {
    --ease-throw: cubic-bezier(0.22, 1.18, 0.36, 1);
    --ease-gather: cubic-bezier(0.33, 1, 0.68, 1);
  }
 
  .card-spread-stage {
    /*
     * overflow-x: auto forces overflow-y: auto — pad vertically so spread
     * tilt and hover peek are not clipped.
     */
    @apply w-full px-4 py-8;
  }
 
  /* Deck: center the wide grid, clip sides — stack sits at grid center (col 1.5). */
  .card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-stage {
    @apply flex items-end justify-center overflow-x-hidden;
  }
 
  /* Spread: horizontal scroll when needed; center the row when it fits. */
  .card-spread:has(.card-spread-toggle:checked) .card-spread-stage {
    @apply overflow-x-auto overscroll-x-contain;
  }
 
  @media (min-width: 64rem) {
    .card-spread:has(.card-spread-toggle:checked) .card-spread-stage {
      @apply flex items-end justify-center;
    }
  }
 
  .card-spread-container {
    --card-gap: 0.75rem;
    /* 4× w-48 + 3× gap-3 — never shrink or deck offsets miss grid tracks */
    @apply grid w-fit min-w-[calc(4*12rem+3*0.75rem)] shrink-0 grid-cols-4 gap-3;
  }
 
  .card-spread-toggle {
    @apply sr-only;
  }
 
  .card-spread-open {
    @apply relative inline-flex cursor-pointer items-center rounded-full border border-border bg-background px-3.5 py-1.5 text-sm font-medium text-foreground transition-colors select-none;
  }
 
  .card-spread-open:hover {
    @apply bg-accent/10;
  }
 
  .card-spread-open:has(.card-spread-toggle:focus-visible) {
    @apply outline-2 outline-[#ffcc00] outline-offset-2;
  }
 
  .card-spread-open__text--collapse {
    @apply hidden;
  }
 
  .card-spread:has(.card-spread-toggle:checked) .card-spread-open__text--expand {
    @apply hidden;
  }
 
  .card-spread:has(.card-spread-toggle:checked) .card-spread-open__text--collapse {
    @apply inline;
  }
 
  .card-spread-item {
    @apply origin-bottom;
  }
 
  .card-spread-item:nth-child(1) {
    @apply z-10;
  }
 
  .card-spread-item:nth-child(2) {
    @apply z-20;
  }
 
  .card-spread-item:nth-child(3) {
    @apply z-30;
  }
 
  .card-spread-item:nth-child(4) {
    @apply z-40;
  }
 
  .card-spread-item__internal {
    @apply origin-bottom motion-reduce:transition-none;
 
    transition: transform 520ms var(--ease-gather);
  }
 
  .card-spread:has(.card-spread-toggle:checked) .card-spread-item__internal {
    transition: transform 480ms var(--ease-throw);
  }
 
  /*
   * Deck — 1×4 grid, pull each card toward col 1.5 via transform.
   * Same explicit calc() on all breakpoints so spread animates on mobile too.
   */
  .card-spread:not(:has(.card-spread-toggle:checked)) .card-spread-item:nth-child(1)
    .card-spread-item__internal {
    transform: translate3d(calc(150% + 1.125rem), 0, 0);
  }
 
  .card-spread:not(:has(.card-spread-toggle:checked))
    .card-spread-item:nth-child(2)
    .card-spread-item__internal {
    transform: translate3d(calc(50% + 0.375rem), 0, 0);
  }
 
  .card-spread:not(:has(.card-spread-toggle:checked))
    .card-spread-item:nth-child(3)
    .card-spread-item__internal {
    transform: translate3d(calc(-50% - 0.375rem), 0, 0);
  }
 
  .card-spread:not(:has(.card-spread-toggle:checked))
    .card-spread-item:nth-child(4)
    .card-spread-item__internal {
    transform: translate3d(calc(-150% - 1.125rem), 0, 0);
  }
 
  /* Spread — layout slots, light rotation */
  .card-spread:has(.card-spread-toggle:checked) .card-spread-item:nth-child(1)
    .card-spread-item__internal {
    transform: translate3d(0, 0, 0) rotate(-2deg);
  }
 
  .card-spread:has(.card-spread-toggle:checked)
    .card-spread-item:nth-child(2)
    .card-spread-item__internal {
    transform: translate3d(0, 0.5rem, 0) rotate(3deg);
  }
 
  .card-spread:has(.card-spread-toggle:checked)
    .card-spread-item:nth-child(3)
    .card-spread-item__internal {
    transform: translate3d(0.25rem, 0, 0) rotate(-2deg);
  }
 
  .card-spread:has(.card-spread-toggle:checked)
    .card-spread-item:nth-child(4)
    .card-spread-item__internal {
    transform: translate3d(0, 0, 0) rotate(2deg);
  }
 
  /* Hover peek — deck only */
  .card-spread-item__hover {
    @apply origin-bottom transition-transform duration-300 ease-out motion-reduce:transition-none;
  }
 
  .card-spread:not(:has(.card-spread-toggle:checked))
    .card-spread-stage:hover
    .card-spread-item:nth-child(1)
    .card-spread-item__hover {
    @apply -rotate-1;
  }
 
  .card-spread:not(:has(.card-spread-toggle:checked))
    .card-spread-stage:hover
    .card-spread-item:nth-child(2)
    .card-spread-item__hover {
    @apply -rotate-2;
  }
 
  .card-spread:not(:has(.card-spread-toggle:checked))
    .card-spread-stage:hover
    .card-spread-item:nth-child(3)
    .card-spread-item__hover {
    @apply rotate-1;
  }
 
  .card-spread:not(:has(.card-spread-toggle:checked))
    .card-spread-stage:hover
    .card-spread-item:nth-child(4)
    .card-spread-item__hover {
    @apply rotate-2;
  }
 
  @media (prefers-reduced-motion: reduce) {
    .card-spread-item__internal,
    .card-spread-item__hover {
      @apply !duration-[0.01ms];
    }
  }
}
import Notes, { NotesCard } from "@/animata/widget/notes";
import ShoppingList from "@/animata/widget/shopping-list";
 
import "./card-spread.css";
 
function Reminders() {
  return (
    <ShoppingList
      title="Reminders"
      items={[
        { id: "museum", title: "Book museum tickets" },
        { id: "groceries", title: "Buy groceries", checked: true },
        { id: "mom", title: "Call mom" },
      ]}
    />
  );
}
 
function RemodelNotes() {
  return (
    <NotesCard title="Kitchen Remodel Ideas">
      <div>Install a farmhouse sink for a rustic touch</div>
      <div>Use classic subway tiles</div>
      <div>Add an island for extra counter space</div>
      <div>Opt for open shelving</div>
    </NotesCard>
  );
}
 
const cards = [
  { id: "notes", component: Notes },
  { id: "shopping-list", component: ShoppingList },
  { id: "remodel-notes", component: RemodelNotes },
  { id: "reminders", component: Reminders },
] as const;
 
export default function CardSpread() {
  return (
    <div className="card-spread flex min-h-96 w-full flex-col items-center justify-center gap-4">
      <label className="card-spread-open">
        <input type="checkbox" className="card-spread-toggle" />
        <span className="card-spread-open__text card-spread-open__text--expand">Spread cards</span>
        <span className="card-spread-open__text card-spread-open__text--collapse">Stack cards</span>
      </label>
 
      <div className="card-spread-stage group/stack">
        <div className="card-spread-container">
          {cards.map((item) => {
            const Card = item.component;
 
            return (
              <div key={item.id} className="card-spread-item w-48 shrink-0">
                <div className="card-spread-item__hover">
                  <div className="card-spread-item__internal">
                    <Card />
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

How it works

The cards always sit in a four-column row at w-48 each. On narrow viewports the stage scrolls horizontally instead of reflowing into fewer columns. That's their real spread position. In the deck state, each card is shifted toward the center of that row so they pile on top of each other — outer cards travel farther, so the throw opens from the middle.

Click Spread cards and those shifts animate back to zero. The cards land in the grid slots they already occupy. Spread uses a quick throw with a slight overshoot (--ease-throw); stacking back up uses a gentler ease-out (--ease-gather). Only transform is animated — nothing about the container width changes, which keeps the motion smooth across browsers.

Hover is separate. While the deck is stacked, moving over the stage gives each card a small rotate nudge on an outer wrapper. That peek does not run once the row is spread, so it never competes with the throw.

State is a checkbox behind the toggle label — no React state, no click handler on the cards themselves. When spread, the tiles underneath stay interactive (checkboxes, links, and so on).

Customization

Swap the card components in the cards array. Keep them at w-48 unless you also update the grid gap and the per-card translate3d(calc(...)) deck offsets in the CSS. The stage uses overflow-x-auto — adjust padding or scroll-snap there if you want a different narrow-screen feel.

To change the throw character, edit --ease-throw and --ease-gather in card-spread.css. Per-card spread tilt and deck hover peek are set per :nth-child in the same file.

Adding or removing cards means updating both the array and the matching CSS rules for deck offset, z-index, and rotation.

Credits

Built by sudha