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
"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;

export default function BlogCardStack({
  heading = "Nos derniers articles",
  subtitle = "Explorez nos reflexions",
  articles = [],
}: BlogCardStackProps) {
  const [isExpanded, setIsExpanded] = useState(false);
  const items = articles.slice(0, 5);

  return (
    <section
      style={{
        padding: "var(--section-padding-y) 0",
        background: "var(--color-background)",
        overflow: "hidden",
      }}
    >
      <div
        style={{
          maxWidth: "var(--container-max-width)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x)",
        }}
      >
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }}
          transition={{ duration: 0.6, ease: EASE }}
          style={{ textAlign: "center", marginBottom: "4rem" }}
        >
          <p style={{ fontSize: "0.8125rem", fontWeight: 600, color: "var(--color-accent)", letterSpacing: "0.08em", textTransform: "uppercase", marginBottom: "0.75rem" }}>
            {subtitle}
          </p>
          <h2 style={{ fontFamily: "var(--font-serif)", fontSize: "clamp(2rem, 4vw, 3.25rem)", fontWeight: 400, fontStyle: "italic", color: "var(--color-foreground)", lineHeight: 1.15 }}>
            {heading}
          </h2>
        </motion.div>

        <div
          onMouseEnter={() => setIsExpanded(true)}
          onMouseLeave={() => setIsExpanded(false)}
          style={{ position: "relative", height: "420px", maxWidth: "640px", margin: "0 auto", cursor: "pointer" }}
        >
          <AnimatePresence>
            {items.map((article, i) => {
              const mid = (items.length - 1) / 2;
              const stackRotate = (i - mid) * 3;
              const fanRotate = (i - mid) * 8;
              const fanX = (i - mid) * 120;

              return (
                <motion.a
                  key={article.title}
                  href={article.url}
                  initial={{ opacity: 0, y: 40, rotate: 0 }}
                  whileInView={{ opacity: 1, y: 0, rotate: stackRotate }}
                  viewport={{ once: true }}
                  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: EASE }}
                  whileHover={{ y: -8 }}
                  style={{
                    position: "absolute",
                    inset: 0,
                    display: "flex",
                    flexDirection: "column",
                    justifyContent: "flex-end",
                    borderRadius: "var(--radius-xl)",
                    overflow: "hidden",
                    textDecoration: "none",
                    boxShadow: `0 ${4 + i * 6}px ${20 + i * 10}px rgba(0,0,0,${0.12 + i * 0.04})`,
                    background: "var(--color-background-card)",
                    border: "1px solid var(--color-border)",
                  }}
                >
                  <div
                    style={{
                      position: "absolute",
                      inset: 0,
                      backgroundImage: `url(${article.image})`,
                      backgroundSize: "cover",
                      backgroundPosition: "center",
                      opacity: 0.15,
                    }}
                  />
                  <div style={{ position: "relative", padding: "2rem", zIndex: 1 }}>
                    <span style={{ display: "inline-block", padding: "0.25rem 0.75rem", borderRadius: "var(--radius-full)", background: "var(--color-accent)", fontSize: "0.75rem", fontWeight: 600, color: "var(--color-foreground)", marginBottom: "0.75rem" }}>
                      {article.tag}
                    </span>
                    <h3 style={{ fontFamily: "var(--font-sans)", fontSize: "1.25rem", fontWeight: 600, color: "var(--color-foreground)", lineHeight: 1.3, marginBottom: "0.5rem" }}>
                      {article.title}
                      <ArrowUpRight style={{ display: "inline", width: 16, height: 16, marginLeft: 6, opacity: 0.5 }} />
                    </h3>
                    <p style={{ fontSize: "0.875rem", color: "var(--color-foreground-muted)", lineHeight: 1.5, marginBottom: "0.75rem" }}>
                      {article.excerpt}
                    </p>
                    <span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontSize: "0.75rem", color: "var(--color-foreground-light)" }}>
                      <Calendar style={{ width: 12, height: 12 }} /> {article.date}
                    </span>
                  </div>
                </motion.a>
              );
            })}
          </AnimatePresence>
        </div>
      </div>
    </section>
  );
}

Avis

Blog Card Stack — React Blog Section — Incubator