3D card that flips on a tuned spring around the Y or X axis to reveal a back face, triggered by hover or click and always by Enter/Space.
Preview
Flip Card
Hover, click or press Enter to turn me over.
Customize
8 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add flip-cardWrites components/ui/flip-card.tsx and components/styles/flip-card.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { FlipCard } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <FlipCard />6 );7}Props
Accessibility
Root is a focusable role="button" with aria-pressed reflecting the flipped state; Enter and Space flip it. The hidden face is aria-hidden so only the visible side is read. Hover-trigger cards also flip on touch tap, and prefers-reduced-motion swaps the spring for an instant flip.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { motion, useReducedMotion } from "motion/react";5import styles from "../styles/flip-card.module.css";6 7export interface FlipCardProps {8 /** Custom front face content (overrides frontTitle/frontText) */9 front?: React.ReactNode;10 /** Custom back face content (overrides backTitle/backText) */11 back?: React.ReactNode;12 /** Front face heading used when `front` is not provided */13 frontTitle?: string;14 /** Front face text used when `front` is not provided */15 frontText?: string;16 /** Back face heading used when `back` is not provided */17 backTitle?: string;18 /** Back face text used when `back` is not provided */19 backText?: string;20 /** What flips the card with a pointer (Enter/Space always flips) */21 trigger?: "hover" | "click";22 /** Rotation axis */23 axis?: "y" | "x";24 /** Card width in pixels */25 width?: number;26 /** Card height in pixels */27 height?: number;28 /** Optional className */29 className?: string;30}31 32export function FlipCard({33 front,34 back,35 frontTitle = "Flip Card",36 frontText = "Hover, click or press Enter to turn me over.",37 backTitle = "Spring-driven 3D",38 backText = "Perspective, backface culling and a tuned spring. Press Enter again to flip back.",39 trigger = "hover",40 axis = "y",41 width = 288,42 height = 224,43 className = "",44}: FlipCardProps) {45 const prefersReducedMotion = useReducedMotion();46 const [flipped, setFlipped] = React.useState(false);47 const lastPointer = React.useRef("mouse");48 const rotateKey = axis === "x" ? "rotateX" : "rotateY";49 50 const onPointerEnter = (e: React.PointerEvent<HTMLDivElement>) => {51 lastPointer.current = e.pointerType;52 if (trigger === "hover" && e.pointerType !== "touch") setFlipped(true);53 };54 const onPointerLeave = (e: React.PointerEvent<HTMLDivElement>) => {55 if (trigger === "hover" && e.pointerType !== "touch") setFlipped(false);56 };57 const onClick = () => {58 if (trigger === "click" || lastPointer.current === "touch") setFlipped((f) => !f);59 };60 const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {61 if (e.key === "Enter" || e.key === " ") {62 e.preventDefault();63 setFlipped((f) => !f);64 }65 };66 67 return (68 <div69 role="button"70 tabIndex={0}71 aria-pressed={flipped}72 aria-roledescription="flip card"73 onPointerEnter={onPointerEnter}74 onPointerDown={(e) => {75 lastPointer.current = e.pointerType;76 }}77 onPointerLeave={onPointerLeave}78 onClick={onClick}79 onKeyDown={onKeyDown}80 style={81 {82 "--mf-fc-w": `${width}px`,83 "--mf-fc-h": `${height}px`,84 } as React.CSSProperties85 }86 className={`${styles.root} ${className}`.trim()}87 >88 <motion.div89 className={styles.inner}90 initial={false}91 animate={{ [rotateKey]: flipped ? 180 : 0 }}92 transition={prefersReducedMotion ? { duration: 0 } : { type: "spring", stiffness: 120, damping: 16 }}93 >94 <div className={`${styles.face} ${styles.front}`} aria-hidden={flipped}>95 {front ?? (96 <>97 <span className={styles.badge} aria-hidden="true">98 <svg viewBox="0 0 24 24" width="18" height="18">99 <path d="M4 12a8 8 0 0 1 14-5.3M20 12a8 8 0 0 1-14 5.3M18 3v4h-4M6 21v-4h4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />100 </svg>101 </span>102 <h3 className={styles.title}>{frontTitle}</h3>103 <p className={styles.text}>{frontText}</p>104 </>105 )}106 </div>107 <div108 className={`${styles.face} ${styles.back}`}109 aria-hidden={!flipped}110 style={{ transform: axis === "x" ? "rotateX(180deg)" : "rotateY(180deg)" }}111 >112 {back ?? (113 <>114 <h3 className={styles.title}>{backTitle}</h3>115 <p className={styles.text}>{backText}</p>116 </>117 )}118 </div>119 </motion.div>120 </div>121 );122}