Retour au catalogue

Blog Card Stack

Articles empiles avec rotation alternee. Au hover, les cards se deplient en eventail avec animation fluide.

blogcomplex Both Responsive a11y
playfulboldagencysaasuniversalcentered
Theme

How to build a stacked card fan effect in React with Framer Motion

A stacked card fan in React places up to 5 cards absolutely inside a fixed-height container, each offset by a small rotation calculated from its index. On hover, Framer Motion animates each card to a wider rotation and an X translation, spreading them into a fan. The whole effect uses a single animate prop driven by an isExpanded boolean.

  • Stack: React + Framer Motion 11 + lucide-react, ~137 lines, zero extra dependencies.
  • Core API: useState, motion.a, AnimatePresence, animate prop with rotate/x/scale.
  • Each card is an anchor tag, so the section is keyboard-navigable and screen-reader friendly.
  • Mobile caveat: the fan expand/collapse relies on mouseenter/mouseleave, which do not fire on touch screens.
  • Supports 3 to 5 articles; the mid-point calculation centers the rotation spread symmetrically.

Blog Card Stack is a React blog section that presents articles as a physical deck of cards. The cards sit stacked with gentle alternating rotations, and a single mouse enter fans them apart into a spread the reader can scan. It turns a list of posts into a tactile, curiosity-driving moment without any scroll.

Anatomy

A centered section header (eyebrow label + italic serif heading) sits above a 420px-high container. Inside it, up to 5 motion.a cards are positioned absolutely, all sharing the same inset:0 so they occupy the same space. Each card has a background image at low opacity, a gradient-like card surface, a category tag badge, the article title with an ArrowUpRight icon, an excerpt, and a date stamp. The cards stack visually through z-index and a per-card box-shadow with increasing blur.

How it works

The stacking and fan logic lives entirely in the animate prop of each motion.a. For a list of N cards, the midpoint is (N-1)/2. In the closed state, each card rotates by (i - mid) * 3 degrees and sits at x:0. In the expanded state, rotation widens to (i - mid) * 8 degrees and x jumps to (i - mid) * 120px. Scale decreases slightly with depth (1 - i * 0.02) when closed and locks to 0.88 for all when open. A custom spring easing array [0.16, 1, 0.3, 1] gives the transition a snappy feel with a soft landing. The whole state flip happens on onMouseEnter/onMouseLeave of the parent container.

How to build it in React

  1. Set up the container and expand state

    Create a relative container with a fixed height (420px works for portrait cards) and maxWidth centered with margin auto. Attach onMouseEnter and onMouseLeave to flip a single isExpanded boolean. Position all child cards absolutely so they occupy the same footprint.

    const [isExpanded, setIsExpanded] = useState(false);
    
    <div
      onMouseEnter={() => setIsExpanded(true)}
      onMouseLeave={() => setIsExpanded(false)}
      style={{ position: "relative", height: "420px", maxWidth: "640px", margin: "0 auto" }}
    >
  2. Calculate per-card rotation and translation

    For each card at index i, compute a mid value as (items.length - 1) / 2. The stack rotation is (i - mid) * 3 degrees; the fan rotation is (i - mid) * 8 degrees. The fan X offset is (i - mid) * 120px, giving roughly 120px between card centers when expanded. These formulas center the spread symmetrically regardless of the number of cards.

    const mid = (items.length - 1) / 2;
    const stackRotate = (i - mid) * 3;
    const fanRotate = (i - mid) * 8;
    const fanX = (i - mid) * 120;
  3. Drive the animate prop with isExpanded

    Pass an animate object to each motion.a that reads isExpanded to choose between stack and fan values. Add a staggered delay of i * 0.05 seconds so cards ripple outward rather than all moving at once. The custom ease array [0.16, 1, 0.3, 1] produces a fast start with a gentle overshoot finish.

    animate={{
      rotate: isExpanded ? fanRotate : stackRotate,
      x: isExpanded ? fanX : 0,
      scale: isExpanded ? 0.88 : 1 - i * 0.02,
      zIndex: items.length - i,
    }}
    transition={{ duration: 0.5, delay: i * 0.05, ease: [0.16, 1, 0.3, 1] }}
  4. Add the whileInView entrance and whileHover lift

    Set initial to opacity:0, y:40, rotate:0 and whileInView to opacity:1, y:0, rotate:stackRotate so cards animate in on scroll independently of the hover state. A simple whileHover of y:-8 lifts the hovered card slightly, reinforcing the physical deck metaphor. Wrap everything in AnimatePresence to handle conditional rendering cleanly.

    initial={{ opacity: 0, y: 40, rotate: 0 }}
    whileInView={{ opacity: 1, y: 0, rotate: stackRotate }}
    whileHover={{ y: -8 }}

When to use it

Blog Card Stack works well in the middle or late section of an agency, SaaS, or editorial landing page where you want to highlight 3 to 5 recent articles without a conventional grid. The interaction invites curiosity without demanding it. Skip it on a dedicated blog index where readers expect scannable rows, and always pair it with a touch-friendly fallback since the fan expand does not trigger on mobile.

Used by

  • Linear, Uses overlapping card-like surfaces with staggered reveals in product feature sections to create depth without cluttering the layout.
  • Stripe, Showcases layered card-deck motifs in its developer product pages to communicate multiple parallel items without a flat list.
  • Craft, Employs stacked document card animations on its landing page to convey the idea of layered notes and linked pages.

FAQ

Does the fan effect work on mobile?

No. The expand/collapse relies on mouseenter and mouseleave events, which do not fire on touch screens. For mobile, keep the cards in their stacked state or replace the interaction with a tap-to-expand toggle.

How many cards can the stack handle?

The component slices the articles array to the first 5 items. Beyond 5, the cards in the stacked state start to overlap awkwardly and the fan spread at 120px intervals exceeds the 640px container. Three to five cards gives the best visual result.

Why use AnimatePresence here?

AnimatePresence handles the case where the articles array changes dynamically, ensuring cards that are removed animate out cleanly rather than disappearing instantly. For a static array it adds little overhead but keeps the component robust for real-world data fetching scenarios.

Can I replace the box-shadow depth trick with something else?

Yes. A common alternative is varying the brightness filter (filter: brightness(0.9 - i * 0.05)) to darken cards deeper in the stack, simulating shadow without a hard drop shadow. You can combine both for a more dramatic sense of physical depth.

"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Calendar, ArrowUpRight } from "lucide-react";

interface Article {
  title: string;
  excerpt: string;
  date: string;
  tag: string;
  image: string;
  url: string;
}

interface BlogCardStackProps {
  heading?: string;
  subtitle?: string;
  articles?: Article[];
}

const EASE = [0.16, 1, 0.3, 1] as const;

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reviews

React Stacked Card Fan Animation, Code + Tutorial