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 motion

Copy the source below and adjust the cn utility import to your project.

components/ui/flip-card.tsx
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

PropTypeRequiredDefaultDescription
front / backReactNodeYes—Content for each face. The back uses the ink colour as its background.
label / backLabelstringNo"Show more details" / "Tap to return"Hint shown on each face, also the button's accessible name.
flipped / defaultFlippedbooleanNo—Controlled or initial side.
onFlippedChange(flipped: boolean) => voidNo—Fires after a click, key press or completed drag.
axis"x" | "y"No"y"Flip around the vertical (y) or horizontal (x) axis.
classNamestringNo—Wrapper classes.

Best Practices

  1. The card is one button, so keep links and other controls off both faces.
  2. Put the hook on the front and the payoff on the back; both faces size to the taller one.
  3. Enter and Space flip it; on touch you can drag sideways while vertical swipes still scroll the page.
  4. Reduced motion swaps sides instantly.