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-motionPreview
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>
);
}