FlipStack

PreviousNext

A stylish and animated card stack UI for showcasing content in layered views.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/flipstack.tsx
"use client";

import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Card, CardContent } from "@/components/ui/card";

type FlipStackCard = {
  id: number;
  content?: React.ReactNode;
};

type FlipStackProps = {
  cards?: FlipStackCard[];
  mobileDirection?: "top" | "bottom";
};

export default function FlipStack({
  cards = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }],
  mobileDirection = "top",
}: FlipStackProps) {
  const [isInView, setIsInView] = useState(false);
  const [isMobile, setIsMobile] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const checkMobile = () => setIsMobile(window.innerWidth < 1024);
    checkMobile();
    window.addEventListener("resize", checkMobile);

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) setIsInView(true);
      },
      { threshold: 0.3 }
    );

    if (containerRef.current) observer.observe(containerRef.current);

    return () => {
      window.removeEventListener("resize", checkMobile);
      if (containerRef.current) observer.unobserve(containerRef.current);
    };
  }, []);

  useEffect(() => {
    if (!isMobile || !isInView) return;

    const interval = setInterval(() => {
      setActiveIndex((prev) => (prev + 1) % cards.length);
    }, 4000);

    return () => clearInterval(interval);
  }, [isMobile, isInView, cards.length]);

  const getRotation = (index: number) => {
    const rotations = [-8, 5, -3, 7, -5, 4, -6, 8, -2, 3];
    return rotations[index % rotations.length];
  };

  const isActive = (index: number) => index === activeIndex;

  const getCardVariants = (index: number) => {
    const totalCards = cards.length;
    const centerIndex = Math.floor(totalCards / 2);
    const positionFromCenter = index - centerIndex;

    if (isMobile) {
      const yInitial = mobileDirection === "bottom" ? -100 : 100;
      const yBounce = mobileDirection === "bottom" ? [0, 80, 0] : [0, -80, 0];

      return {
        initial: {
          opacity: 0,
          scale: 0.9,
          z: -100,
          rotate: getRotation(index),
          y: yInitial,
        },
        animate: {
          opacity: isActive(index) ? 1 : 0.7,
          scale: isActive(index) ? 1 : 0.95,
          z: isActive(index) ? 0 : -100,
          rotate: isActive(index) ? 0 : getRotation(index),
          zIndex: isActive(index) ? 40 : totalCards + 2 - index,
          y: isActive(index) ? yBounce : 0,
        },
      };
    }

    return {
      initial: {
        x: 0,
        y: index * 8 + 100,
        rotate: getRotation(index),
        scale: 1,
        zIndex: totalCards - index,
      },
      animate: {
        x: positionFromCenter * 140,
        y: Math.abs(positionFromCenter) * 30,
        rotate: positionFromCenter * 12,
        scale: 1,
        zIndex: totalCards - Math.abs(positionFromCenter),
      },
    };
  };

  return (
    <div className="h-full w-full py-2">
      <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
        <div className="flex justify-center items-center">
          <div
            ref={containerRef}
            className="relative h-96 w-full max-w-md mx-auto"
          >
            {isMobile ? (
              <div className="relative h-full w-full">
                <AnimatePresence>
                  {cards.map((card, index) => {
                    const variants = getCardVariants(index);
                    return (
                      <motion.div
                        key={card.id}
                        className="absolute inset-0 origin-bottom"
                        initial="initial"
                        animate={isInView ? "animate" : "initial"}
                        exit={{
                          opacity: 0,
                          scale: 0.9,
                          z: 100,
                          rotate: getRotation(index),
                        }}
                        variants={variants}
                        transition={{ duration: 0.4, ease: "easeInOut" }}
                      >
                        <Card className="w-full h-full shadow-2xl border-0 bg-white dark:bg-gray-800 overflow-hidden">
                          <CardContent className="p-0 h-full flex items-center justify-center">
                            {card.content}
                          </CardContent>
                        </Card>
                      </motion.div>
                    );
                  })}
                </AnimatePresence>
              </div>
            ) : (
              <div
                className="relative h-full w-full flex items-center justify-center"
                style={{ perspective: "1000px" }}
              >
                {cards.map((card, index) => {
                  const variants = getCardVariants(index);
                  return (
                    <motion.div
                      key={card.id}
                      className="absolute origin-bottom"
                      initial="initial"
                      animate={isInView ? "animate" : "initial"}
                      variants={variants}
                      transition={{
                        duration: 0.8,
                        delay: index * 0.1,
                        ease: "easeOut",
                      }}
                    >
                      <Card className="w-80 h-96 shadow-2xl border-0 bg-white dark:bg-gray-800 overflow-hidden">
                        <CardContent className="p-0 h-full flex items-center justify-center">
                          {card.content}
                        </CardContent>
                      </Card>
                    </motion.div>
                  );
                })}
              </div>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

Installation

npx shadcn@latest add @scrollxui/flipstack

Usage

import { Flipstack } from "@/components/flipstack"
<Flipstack />