Flip Card

PreviousNext

Experience smooth, fluid card navigation with natural drag interactions and beautiful animations.

Docs
bunduicomponent

Preview

Loading preview…
examples/motion/animations/flip-card/01/flip-card.tsx
"use client";

import React, { useState, useCallback, useMemo, ReactNode } from "react";
import { motion, AnimatePresence, PanInfo } from "motion/react";

type FlipCardProps = {
  children: ReactNode[];
  stackOffset?: number;
  stackRotation?: number;
  dragThreshold?: number;
  borderRadius?: number;
  shadowIntensity?: number;
};

export default function FlipCard({
  children,
  stackOffset = 20,
  stackRotation = 8,
  dragThreshold = 120,
  borderRadius = 10,
  shadowIntensity = 0.2
}: FlipCardProps) {
  const childCount = React.Children.count(children);
  const [cardOrder, setCardOrder] = useState(() => Array.from({ length: childCount }, (_, i) => i));

  const getCardTransform = useCallback(
    (index: number, childIndex: number) => {
      const stackPosition = cardOrder.indexOf(childIndex);
      const positionFromBottom = cardOrder.length - 1 - stackPosition;

      return {
        zIndex: stackPosition,
        y: -positionFromBottom * stackOffset,
        rotate: positionFromBottom * stackRotation,
        scale: 1 - positionFromBottom * 0.02,
        opacity: 1
      };
    },
    [cardOrder, stackOffset, stackRotation]
  );

  const handleDragEnd = useCallback(
    (event: any, info: PanInfo, cardIndex: number) => {
      const dragDistance = Math.abs(info.offset.x) + Math.abs(info.offset.y);
      const velocity = Math.abs(info.velocity.x) + Math.abs(info.velocity.y);

      if (dragDistance > dragThreshold || velocity > 800) {
        setCardOrder((prevOrder) => {
          const newOrder = [...prevOrder];
          const draggedCardPosition = newOrder.indexOf(cardIndex);
          const draggedCard = newOrder.splice(draggedCardPosition, 1)[0];
          newOrder.unshift(draggedCard);
          return newOrder;
        });
      }
    },
    [dragThreshold]
  );

  const cardVariants = {
    initial: (custom: any) => ({ ...custom, x: 0, y: custom.y }),
    animate: (custom: any) => ({
      ...custom,
      x: 0,
      y: custom.y,
      transition: {
        type: "spring",
        damping: 30,
        stiffness: 500,
        mass: 0.5,
        restDelta: 0.01,
        restSpeed: 0.01
      }
    }),
    drag: {
      scale: 1.05,
      rotate: 0,
      transition: { duration: 0.05 }
    }
  };

  const renderedCards = useMemo(() => {
    return React.Children.map(children, (child, index) => {
      const transform = getCardTransform(index, index);
      const isTopCard = cardOrder.indexOf(index) === cardOrder.length - 1;

      return (
        <motion.div
          key={`card-${index}`}
          custom={transform}
          variants={cardVariants}
          initial="initial"
          animate="animate"
          exit="exit"
          whileDrag="drag"
          drag={isTopCard}
          dragConstraints={{
            top: -150,
            bottom: 150,
            left: -150,
            right: 150
          }}
          dragElastic={0.2}
          dragSnapToOrigin={true}
          dragTransition={{
            power: 0.3,
            timeConstant: 125,
            bounceStiffness: 500,
            bounceDamping: 30
          }}
          onDragEnd={(event, info) => handleDragEnd(event, info, index)}
          className={`absolute overflow-hidden ${isTopCard ? "cursor-grab" : "cursor-default"}`}
          style={{
            borderRadius,
            zIndex: transform.zIndex,
            boxShadow: `0px ${4 + transform.zIndex * 2}px ${
              8 + transform.zIndex * 4
            }px rgba(0,0,0,${shadowIntensity})`
          }}>
          {child}
        </motion.div>
      );
    });
  }, [children, cardOrder, borderRadius, shadowIntensity, getCardTransform]);

  return <AnimatePresence>{renderedCards}</AnimatePresence>;
}

Installation

npx shadcn@latest add @bundui/flip-card

Usage

import { FlipCard } from "@/components/flip-card"
<FlipCard />