Flip Card

3D hover flip with spring physics and a drop shadow that lifts and softens at mid-rotation

cardflip3dhoverspring
Install dependencies
$npm install framer-motion
Preview

Source Code

"use client";

import { useEffect, useState } from "react";
import {
  motion,
  useReducedMotion,
  useSpring,
  useTransform,
} from "framer-motion";

interface FlipCardProps {
  front: React.ReactNode;
  back: React.ReactNode;
  direction?: "horizontal" | "vertical";
  trigger?: "hover" | "click";
  className?: string;
}

export default function FlipCard({
  front,
  back,
  direction = "horizontal",
  trigger = "hover",
  className = "",
}: FlipCardProps) {
  const [flipped, setFlipped] = useState(false);
  const reduceMotion = useReducedMotion();

  const rotation = useSpring(0, { stiffness: 220, damping: 22 });

  useEffect(() => {
    if (reduceMotion) {
      rotation.jump(flipped ? 180 : 0);
    } else {
      rotation.set(flipped ? 180 : 0);
    }
  }, [flipped, reduceMotion, rotation]);

  const axis = direction === "vertical" ? "X" : "Y";
  const transform = useTransform(rotation, (v) => `rotate${axis}(${v}deg)`);

  // Shadow lifts and softens as the card passes through mid-flip
  const shadowScale = useTransform(rotation, [0, 90, 180], [1, 1.12, 1]);
  const shadowOpacity = useTransform(rotation, [0, 90, 180], [0.45, 0.85, 0.45]);
  const shadowBlur = useTransform(
    rotation,
    [0, 90, 180],
    ["blur(16px)", "blur(32px)", "blur(16px)"]
  );

  const hoverHandlers =
    trigger === "hover"
      ? {
          onMouseEnter: () => setFlipped(true),
          onMouseLeave: () => setFlipped(false),
        }
      : {};

  return (
    <div
      className={`relative inline-block ${className}`}
      style={{ perspective: 1200 }}
      {...hoverHandlers}
      onClick={() => trigger === "click" && setFlipped((f) => !f)}
      onKeyDown={(e) => {
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
          setFlipped((f) => !f);
        }
      }}
      role="button"
      tabIndex={0}
      aria-pressed={flipped}
    >
      <motion.div
        aria-hidden="true"
        className="absolute inset-2 -z-10 rounded-2xl bg-black"
        style={{
          scale: shadowScale,
          opacity: shadowOpacity,
          filter: shadowBlur,
          y: 12,
        }}
      />
      <motion.div
        className="relative h-full w-full"
        style={{ transform, transformStyle: "preserve-3d" }}
      >
        <div
          className="h-full w-full"
          style={{ backfaceVisibility: "hidden" }}
          aria-hidden={flipped}
        >
          {front}
        </div>
        <div
          className="absolute inset-0"
          style={{
            backfaceVisibility: "hidden",
            transform: `rotate${axis}(180deg)`,
          }}
          aria-hidden={!flipped}
        >
          {back}
        </div>
      </motion.div>
    </div>
  );
}