Overview
A two-sided card that flips on a spring. Click it, or grab it and turn it by hand: past a quarter turn, or with a quick flick, it completes the flip in that direction; otherwise it settles back. Mid-flip the card lifts toward you and the turning face darkens, so it reads as a solid object. It can flip around either axis, be controlled from outside, and keeps the hidden face out of the tab order.
Installation
$ pnpm add motionCopy the source below and adjust the cn utility import to your project.
tsx
"use client"; import React, { useEffect, useRef, useState } from "react";import type { ReactNode } from "react";import { animate, motion, useMotionValue, useReducedMotion, useTransform, useVelocity,} from "motion/react";import { cn } from "@/lib/utils"; export type FlipCardProps = { front: ReactNode; back: ReactNode; /** Accessible name / hint on the front. */ label?: string; /** Hint shown on the back. */ backLabel?: string; flipped?: boolean; defaultFlipped?: boolean; onFlippedChange?: (flipped: boolean) => void; /** Flip around the vertical (`y`) or horizontal (`x`) axis. */ axis?: "x" | "y"; className?: string;}; const SPRING = { type: "spring" as const, stiffness: 160, damping: 20, mass: 0.9 };const DRAG_RATIO = 0.55; // degrees per px export function FlipCard({ front, back, label = "Show more details", backLabel = "Tap to return", flipped: flippedProp, defaultFlipped = false, onFlippedChange, axis = "y", className,}: FlipCardProps) { const reduce = !!useReducedMotion(); const isControlled = flippedProp !== undefined; const [uncontrolled, setUncontrolled] = useState(defaultFlipped); const flipped = isControlled ? flippedProp : uncontrolled; // `rotation` accumulates, so flipping twice in the same direction keeps // spinning forward instead of unwinding. const base = useRef(flipped ? 180 : 0); const rotation = useMotionValue(base.current); const velocity = useVelocity(rotation); const drag = useRef<{ start: number; moved: boolean } | null>(null); const suppressClick = useRef(false); const [dragging, setDragging] = useState(false); const rad = useTransform(rotation, (r) => (r * Math.PI) / 180); // Lift toward the viewer mid-flip, and shade the face that turns away. const scale = useTransform(rad, (a) => 1 + 0.06 * Math.abs(Math.sin(a))); const shade = useTransform(rad, (a) => Math.abs(Math.sin(a)) * 0.35); const shadow = useTransform(rad, (a) => { const lift = Math.abs(Math.sin(a)); return `0 ${10 + lift * 24}px ${28 + lift * 40}px -18px rgba(0,0,0,${0.18 + lift * 0.2})`; }); const rotate = axis === "y" ? { rotateY: rotation } : { rotateX: rotation }; const settle = (target: number, v = 0) => { base.current = target; if (reduce) rotation.jump(target); else animate(rotation, target, { ...SPRING, velocity: v }); }; // Follow the controlled prop. useEffect(() => { const showingBack = Math.round(base.current / 180) % 2 !== 0; if (showingBack !== flipped) settle(base.current + 180); // eslint-disable-next-line react-hooks/exhaustive-deps }, [flipped]); const setFlipped = (next: boolean, target: number, v = 0) => { settle(target, v); if (!isControlled) setUncontrolled(next); onFlippedChange?.(next); }; const flip = (dir: 1 | -1 = 1, v = 0) => setFlipped(!flipped, base.current + 180 * dir, v); const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => { if (reduce || e.button !== 0) return; drag.current = { start: axis === "y" ? e.clientX : e.clientY, moved: false }; }; const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => { const d = drag.current; if (!d) return; const delta = (axis === "y" ? e.clientX : -e.clientY) - (axis === "y" ? d.start : -d.start); if (!d.moved) { if (Math.abs(delta) < 6) return; d.moved = true; setDragging(true); try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* Pointer may already be gone. */ } } rotation.set(base.current + delta * DRAG_RATIO); }; const onPointerUp = () => { const d = drag.current; drag.current = null; if (!d?.moved) return; setDragging(false); suppressClick.current = true; const turned = rotation.get() - base.current; const v = velocity.get(); // Past a quarter turn, or a quick flick, completes the flip that way. if (Math.abs(turned) > 70 || Math.abs(v) > 500) flip(Math.sign(turned || v) as 1 | -1, v); else settle(base.current, v); }; const face = "col-start-1 row-start-1 rounded-[20px] [backface-visibility:hidden] [-webkit-backface-visibility:hidden]"; return ( <div className={cn("w-full", className)} style={{ perspective: 1200 }}> <motion.button type="button" aria-pressed={flipped} aria-label={flipped ? backLabel : label} onClick={(e) => { if (suppressClick.current) { suppressClick.current = false; return; } flip(1); // Keyboard flips keep focus; pointer flips don't need a ring. if (e.detail > 0) e.currentTarget.blur(); }} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} whileHover={reduce || dragging ? undefined : { y: -2 }} className={cn( "grid w-full touch-pan-y rounded-[20px] text-left outline-none select-none", "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-4 focus-visible:ring-offset-(--color-paper)", dragging ? "cursor-grabbing" : "cursor-pointer", )} style={{ ...rotate, scale, boxShadow: shadow, transformStyle: "preserve-3d" }} > <div aria-hidden={flipped} inert={flipped} className={cn(face, "relative flex min-h-56 flex-col bg-(--color-paper) p-6 shadow-[inset_0_0_0_1px_var(--color-rule)]")} > <div className="flex-1">{front}</div> <div className="mt-6 flex items-center gap-1.5 text-[12px] text-(--color-neutral)"> {label} <span aria-hidden>↻</span> </div> <motion.div aria-hidden className="pointer-events-none absolute inset-0 rounded-[20px] bg-black" style={{ opacity: shade }} /> </div> <div aria-hidden={!flipped} inert={!flipped} className={cn(face, "relative flex min-h-56 flex-col bg-(--color-ink) p-6 text-(--color-paper)")} style={{ transform: axis === "y" ? "rotateY(180deg)" : "rotateX(180deg)" }} > <div className="flex-1">{back}</div> <div className="mt-6 flex items-center gap-1.5 text-[12px] opacity-60"> {backLabel} <span aria-hidden>↺</span> </div> <motion.div aria-hidden className="pointer-events-none absolute inset-0 rounded-[20px] bg-black" style={{ opacity: shade }} /> </div> </motion.button> </div> );}
Usage
tsx
import { FlipCard } from "@/components/ui/flip-card"; <FlipCard label="Flip for the detail" backLabel="Flip back" front={<h3>Make the quiet parts count.</h3>} back={<p>Good motion gives a change a place to come from.</p>}/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| front / back | ReactNode | Yes | — | Content for each face. The back uses the ink colour as its background. |
| label / backLabel | string | No | "Show more details" / "Tap to return" | Hint shown on each face, also the button's accessible name. |
| flipped / defaultFlipped | boolean | No | — | Controlled or initial side. |
| onFlippedChange | (flipped: boolean) => void | No | — | Fires after a click, key press or completed drag. |
| axis | "x" | "y" | No | "y" | Flip around the vertical (y) or horizontal (x) axis. |
| className | string | No | — | Wrapper classes. |
Best Practices
- The card is one button, so keep links and other controls off both faces.
- Put the hook on the front and the payoff on the back; both faces size to the taller one.
- Enter and Space flip it; on touch you can drag sideways while vertical swipes still scroll the page.
- Reduced motion swaps sides instantly.