Skip to content

3D Flip Card

A card with real thickness that turns on a spring: a shaded rim, a glare that sweeps the face turning toward you, a floor shadow that narrows mid-turn, and a lean toward the pointer. Flips on hover, focus, click, tap or Enter. No dependencies.

DOM + CSS0 deps1 file · 15 KB TSXreduced-motion aware

Freeplayfulglass
Preview

Props

6
No runtime dependencies
"use client";

/*
 * FlipCard: a card with real thickness that turns over on a spring.
 *
 * Three nested layers do the 3D: a perspective layer, a tilt layer that leans
 * toward the pointer, and a flipper that turns on the Y axis. Both faces sit on
 * the flipper with backface-visibility hidden, and a stack of thin slabs between
 * them gives the card a rim you can see when it is edge-on. The turn is a
 * hand-written spring (a little weight, a hint of overshoot); the same angle
 * drives a glare sweep across whichever face is turning toward you, and a
 * floor shadow that narrows and fades while the card lifts mid-turn.
 * Reduced motion drops the rotation and crossfades the faces instead.
 */

import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from "react";

export interface FlipCardProps {
  /** Text on the front face. */
  frontLabel?: string;
  /** Text on the back face. */
  backLabel?: string;
  /** "hover" flips while the pointer is over the card or it has keyboard focus (a tap toggles on touch); "click" toggles on click, tap, Enter or Space. */
  trigger?: "hover" | "click";
  /** Replaces the front label with your own content. */
  front?: ReactNode;
  /** Replaces the back label with your own content. */
  back?: ReactNode;
  /** How far the card leans toward the pointer, in degrees. 0 turns the lean off. */
  tilt?: number;
  /** Extra classes for the root. The root is the card box: size it here (the default is h-40 w-64). */
  className?: string;
  /** Called with true when the back face turns toward you, false when the front does. */
  onFlipChange?: (flipped: boolean) => void;
}

/* ---- Motion constants ---------------------------------------------------- */

/** Flip spring: stiffness, damping. Damping ratio ~0.78, so one small overshoot (about 2 degrees). */
const FLIP_K = 170;
const FLIP_C = 20.4;
/** Lean spring, a touch softer than the flip. */
const LEAN_K = 120;
const LEAN_C = 17;
/** How fast the pointer glare follows the pointer and fades (per second). */
const GLARE_RATE = 14;
/** The card lifts toward you by up to this many px at the middle of the turn. */
const LIFT = 28;

const nowMs = () => performance.now();

function subscribeMotion(cb: () => void) {
  const m = window.matchMedia("(prefers-reduced-motion: reduce)");
  m.addEventListener("change", cb);
  return () => m.removeEventListener("change", cb);
}

/** True when the visitor asked for reduced motion. False on the server. */
function useReducedMotion(): boolean {
  return useSyncExternalStore(
    subscribeMotion,
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
    () => false,
  );
}

interface Spring {
  x: number;
  v: number;
}

function stepSpring(s: Spring, target: number, k: number, c: number, dt: number) {
  s.v += (k * (target - s.x) - c * s.v) * dt;
  s.x += s.v * dt;
}

/** Slab colours between the front rim and the back rim, so the edge shades from one face to the other. */
const SLABS = Array.from({ length: 10 }, (_, i) => {
  const k = (i + 0.5) / 10 - 0.5;
  const m = i / 9;
  const mix = (a: number, b: number) => Math.round(a + (b - a) * m);
  return { z: k * 0.9, color: `rgb(${mix(40, 134)} ${mix(41, 28)} ${mix(47, 30)})` };
});

const FACE_RADIUS = "5.2cqw";

const glareBackground = [
  "radial-gradient(60% 80% at var(--gx) var(--gy), rgb(255 255 255 / calc(var(--pa) * 0.16)), transparent 70%)",
  "linear-gradient(105deg, transparent calc(var(--sw) * 1% - 16%), rgb(255 255 255 / var(--sa)) calc(var(--sw) * 1%), transparent calc(var(--sw) * 1% + 16%))",
].join(",");

function Face({ side, hidden, reduced, decor, children }: { side: "front" | "back"; hidden: boolean; reduced: boolean; decor: boolean; children: ReactNode }) {
  const front = side === "front";
  const style: CSSProperties = {
    borderRadius: FACE_RADIUS,
    backfaceVisibility: "hidden",
    WebkitBackfaceVisibility: "hidden",
    transform: reduced ? undefined : front ? "translateZ(calc(var(--t) / 2))" : "rotateY(180deg) translateZ(calc(var(--t) / 2))",
    background: front
      ? "radial-gradient(120% 90% at 16% 0%, rgb(255 255 255 / 0.1), transparent 55%), linear-gradient(155deg, #1d1e23 0%, #121317 55%, #0d0e11 100%)"
      : "radial-gradient(110% 80% at 85% 0%, rgb(255 255 255 / 0.2), transparent 52%), linear-gradient(150deg, #ff5b49 0%, #e5332b 46%, #a1161d 100%)",
    boxShadow: front
      ? "inset 0 0 0 1px rgb(255 255 255 / 0.1), inset 0 1px 0 rgb(255 255 255 / 0.16)"
      : "inset 0 0 0 1px rgb(255 255 255 / 0.18), inset 0 1px 0 rgb(255 255 255 / 0.3)",
    color: front ? "#f4f4f5" : "#ffffff",
    opacity: reduced ? (hidden ? 0 : 1) : undefined,
    transition: reduced ? "opacity 220ms ease" : undefined,
    pointerEvents: reduced && hidden ? "none" : undefined,
  };
  return (
    <div aria-hidden={hidden || undefined} className="absolute inset-0 overflow-hidden" style={style}>
      {front ? null : (
        <svg aria-hidden className="pointer-events-none absolute inset-0 h-full w-full" viewBox="0 0 160 100" preserveAspectRatio="xMidYMid slice" fill="none" stroke="#fff" strokeWidth="0.5" strokeOpacity="0.16">
          <circle cx="142" cy="112" r="38" />
          <circle cx="142" cy="112" r="62" />
          <circle cx="142" cy="112" r="86" />
          <circle cx="142" cy="112" r="110" />
        </svg>
      )}
      <div className="relative flex h-full w-full items-center justify-center px-[8cqw] text-center">{children}</div>
      {front && decor ? (
        <>
          <svg aria-hidden className="pointer-events-none absolute left-[6cqw] top-[6cqw] h-[5cqw] w-[5cqw] min-h-3 min-w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" opacity="0.38">
            <circle cx="12" cy="12" r="9" />
            <circle cx="12" cy="12" r="3.6" />
          </svg>
          <svg aria-hidden className="pointer-events-none absolute bottom-[6cqw] right-[6cqw] h-[5.4cqw] w-[5.4cqw] min-h-3.5 min-w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" opacity="0.4">
            <path d="M20 11a8 8 0 0 0-14-4.5M4 4v3.5h3.5M4 13a8 8 0 0 0 14 4.5M20 20v-3.5h-3.5" />
          </svg>
        </>
      ) : null}
      {reduced ? null : <div aria-hidden className="pointer-events-none absolute inset-0" style={{ background: glareBackground }} />}
    </div>
  );
}

export function FlipCard({
  frontLabel = "Hover to flip",
  backLabel = "Here's the back",
  trigger = "hover",
  front,
  back,
  tilt = 6,
  className,
  onFlipChange,
}: FlipCardProps) {
  const reduced = useReducedMotion();
  const [clicked, setClicked] = useState(false);
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const [tapped, setTapped] = useState(false);
  const flipped = trigger === "click" ? clicked : hovered || focused || tapped;

  const rootRef = useRef<HTMLDivElement>(null);
  const tiltRef = useRef<HTMLDivElement>(null);
  const flipRef = useRef<HTMLDivElement>(null);
  const shadowRef = useRef<HTMLDivElement>(null);
  const lastPointer = useRef("mouse");
  const wake = useRef<() => void>(() => {});
  const target = useRef({ angle: 0, rx: 0, ry: 0, gx: 50, gy: 50, pa: 0 });
  const tiltMax = useRef(tilt);
  const onFlip = useRef(onFlipChange);
  const prevFlipped = useRef(false);

  useEffect(() => {
    tiltMax.current = tilt;
    onFlip.current = onFlipChange;
  });

  /* The spring loop: runs only while something is moving. */
  useEffect(() => {
    const tl = tiltRef.current;
    const fl = flipRef.current;
    const sh = shadowRef.current;
    const root = rootRef.current;
    if (!tl || !fl || !sh || !root) return;
    if (reduced) {
      tl.style.transform = "";
      fl.style.transform = "";
      sh.style.transform = "";
      sh.style.opacity = "";
      wake.current = () => {};
      return;
    }
    const tg = target.current;
    const angle: Spring = { x: tg.angle, v: 0 };
    const rx: Spring = { x: 0, v: 0 };
    const ry: Spring = { x: 0, v: 0 };
    let gx = tg.gx;
    let gy = tg.gy;
    let pa = 0;
    let raf = 0;
    let last = 0;

    const apply = () => {
      const rad = (angle.x * Math.PI) / 180;
      const sn = Math.abs(Math.sin(rad));
      const cs = Math.abs(Math.cos(rad));
      fl.style.transform = `translateZ(${(sn * LIFT).toFixed(2)}px) rotateY(${angle.x.toFixed(3)}deg)`;
      tl.style.transform = `rotateX(${rx.x.toFixed(3)}deg) rotateY(${ry.x.toFixed(3)}deg)`;
      sh.style.transform = `translateX(-50%) scale(${(0.4 + 0.6 * cs).toFixed(3)}, ${(1 - 0.3 * sn).toFixed(3)})`;
      sh.style.opacity = (1 - 0.6 * sn).toFixed(3);
      const p = angle.x / 180;
      const q = p <= 0.5 ? p : p - 0.5;
      root.style.setProperty("--sw", (-30 + 260 * q).toFixed(2));
      root.style.setProperty("--sa", (0.5 * Math.max(0, Math.sin(2 * Math.PI * q))).toFixed(3));
      root.style.setProperty("--gx", gx.toFixed(2) + "%");
      root.style.setProperty("--gy", gy.toFixed(2) + "%");
      root.style.setProperty("--pa", pa.toFixed(3));
    };

    const step = (t: number) => {
      raf = 0;
      const dt = Math.min(0.032, Math.max(0.001, (t - last) / 1000));
      last = t;
      const h = dt / 2;
      for (let i = 0; i < 2; i++) {
        stepSpring(angle, tg.angle, FLIP_K, FLIP_C, h);
        stepSpring(rx, tg.rx, LEAN_K, LEAN_C, h);
        stepSpring(ry, tg.ry, LEAN_K, LEAN_C, h);
      }
      const f = 1 - Math.exp(-dt * GLARE_RATE);
      gx += (tg.gx - gx) * f;
      gy += (tg.gy - gy) * f;
      pa += (tg.pa - pa) * f;
      const settled =
        Math.abs(angle.x - tg.angle) < 0.04 && Math.abs(angle.v) < 0.6 &&
        Math.abs(rx.x - tg.rx) < 0.02 && Math.abs(rx.v) < 0.1 &&
        Math.abs(ry.x - tg.ry) < 0.02 && Math.abs(ry.v) < 0.1 &&
        Math.abs(pa - tg.pa) < 0.004 && Math.abs(gx - tg.gx) < 0.1 && Math.abs(gy - tg.gy) < 0.1;
      if (settled) {
        angle.x = tg.angle; angle.v = 0;
        rx.x = tg.rx; rx.v = 0;
        ry.x = tg.ry; ry.v = 0;
        gx = tg.gx; gy = tg.gy; pa = tg.pa;
        apply();
        return;
      }
      apply();
      raf = requestAnimationFrame(step);
    };

    wake.current = () => {
      if (raf) return;
      last = nowMs();
      raf = requestAnimationFrame(step);
    };
    apply();
    wake.current();
    return () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      wake.current = () => {};
    };
  }, [reduced]);

  /* A new face is wanted: aim the spring and tell the host. */
  useEffect(() => {
    target.current.angle = flipped ? 180 : 0;
    wake.current();
    if (prevFlipped.current !== flipped) {
      prevFlipped.current = flipped;
      onFlip.current?.(flipped);
    }
  }, [flipped]);

  const aim = (e: PointerEvent<HTMLDivElement>) => {
    const el = rootRef.current;
    if (!el || reduced || e.pointerType === "touch") return;
    const r = el.getBoundingClientRect();
    if (r.width === 0 || r.height === 0) return;
    // Layout pixels: the library scales previews with a transform.
    const x = (e.clientX - r.left) * (el.offsetWidth / r.width);
    const y = (e.clientY - r.top) * (el.offsetHeight / r.height);
    const nx = Math.max(-1, Math.min(1, (x / el.offsetWidth) * 2 - 1));
    const ny = Math.max(-1, Math.min(1, (y / el.offsetHeight) * 2 - 1));
    const t = target.current;
    t.ry = nx * tiltMax.current;
    t.rx = -ny * tiltMax.current;
    t.gx = (x / el.offsetWidth) * 100;
    t.gy = (y / el.offsetHeight) * 100;
    t.pa = 1;
    wake.current();
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (trigger === "click" && (e.key === "Enter" || e.key === " ")) {
      e.preventDefault();
      setClicked((v) => !v);
    }
  };

  const rootStyle = {
    "--sw": -30,
    "--sa": 0,
    "--gx": "50%",
    "--gy": "50%",
    "--pa": 0,
  } as CSSProperties;

  return (
    <div
      ref={rootRef}
      tabIndex={0}
      role={trigger === "click" ? "button" : "group"}
      aria-roledescription={trigger === "click" ? undefined : "flip card"}
      aria-pressed={trigger === "click" ? flipped : undefined}
      className={`group relative block h-40 w-64 max-w-full cursor-pointer select-none [container-type:inline-size] [touch-action:manipulation] focus-visible:!outline-none${className ? " " + className : ""}`}
      style={rootStyle}
      onPointerDown={(e) => {
        lastPointer.current = e.pointerType;
      }}
      onPointerEnter={(e) => {
        if (e.pointerType !== "touch") setHovered(true);
      }}
      onPointerMove={aim}
      onPointerLeave={(e) => {
        if (e.pointerType === "touch") return;
        setHovered(false);
        const t = target.current;
        t.rx = 0;
        t.ry = 0;
        t.pa = 0;
        wake.current();
      }}
      onClick={() => {
        if (trigger === "click") setClicked((v) => !v);
        else if (lastPointer.current === "touch") setTapped((v) => !v);
      }}
      onKeyDown={onKeyDown}
      onFocus={(e) => {
        if (e.currentTarget.matches(":focus-visible")) setFocused(true);
      }}
      onBlur={() => {
        setFocused(false);
        setTapped(false);
      }}
    >
      {/* Floor shadow: narrows and fades while the card is lifted mid-turn. */}
      <div
        ref={shadowRef}
        aria-hidden
        className="pointer-events-none absolute left-1/2 top-[94%] h-[15%] w-[86%] [transform-origin:50%_0%] [transform:translateX(-50%)]"
        style={{ background: "radial-gradient(closest-side, rgb(0 0 0 / 0.4), rgb(0 0 0 / 0.14) 55%, rgb(0 0 0 / 0))" }}
      />
      <div className="absolute inset-0" style={{ perspective: "max(900px, 230cqw)", ["--t" as string]: "clamp(6px, 2.4cqw, 16px)" } as CSSProperties}>
        <div ref={tiltRef} className="absolute inset-0 [transform-style:preserve-3d]">
          <div ref={flipRef} className="absolute inset-0 [transform-style:preserve-3d]">
            {reduced
              ? null
              : SLABS.map((s) => (
                  <div
                    key={s.z}
                    aria-hidden
                    className="absolute inset-px"
                    style={{ borderRadius: FACE_RADIUS, background: s.color, transform: `translateZ(calc(var(--t) * ${s.z.toFixed(3)}))` }}
                  />
                ))}
            <Face side="front" hidden={flipped} reduced={reduced} decor={front === undefined}>
              {front ?? <span className="text-balance text-[clamp(14px,7cqw,44px)] font-semibold leading-tight tracking-[-0.02em]">{frontLabel}</span>}
            </Face>
            <Face side="back" hidden={!flipped} reduced={reduced} decor={back === undefined}>
              {back ?? <span className="text-balance text-[clamp(14px,7cqw,44px)] font-semibold leading-tight tracking-[-0.02em]">{backLabel}</span>}
            </Face>
          </div>
        </div>
      </div>
      {/* Focus ring: two-tone so it reads on light and dark pages. */}
      <div
        aria-hidden
        className="pointer-events-none absolute opacity-0 group-focus-visible:opacity-100"
        style={{ inset: -6, borderRadius: "calc(5.2cqw + 6px)", boxShadow: "0 0 0 2px #fff, 0 0 0 4px #ef4444" }}
      />
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

A flip card shows two states of one object without a cross-fade that makes them feel unrelated. This one is a thing with weight. Three nested layers do the 3D: a perspective layer, a tilt layer that leans the card a few degrees toward the pointer, and a flipper that turns on the Y axis. Both faces use backface-visibility: hidden, and ten thin slabs between them give the card a shaded rim you see when it is edge-on. The turn is a spring (stiffness 170, damping 20.4) with one small overshoot, run in a single requestAnimationFrame loop that sleeps when the card is at rest. The loop writes the angle and nothing else is animated by hand: the card lifts toward you by up to 28px at the middle of the turn, a soft glare band sweeps across whichever face is turning toward you, and a floor shadow narrows and fades while the card is lifted. A faint highlight follows the pointer when it hovers. In hover mode (the default) the card flips while the pointer is over it or it has keyboard focus, and a tap toggles it on touch; in click mode a click, tap, Enter or Space toggles it. Pass front and back to put your own content on the faces, size the card through className (the default is h-40 w-64; type scales with the card width), and use onFlipChange to follow the state. Skip it for faces that must stay legible mid-turn: at 90 degrees the card is edge-on, so don't put a running timer or a live form on either side.

Curator’s note

Rebuilt in October without Framer Motion: the turn is a small hand-written spring, and the same angle drives the glare, the lift and the shadow, which is what makes it feel like an object and not a rotating rectangle.

Pairs well with

Tilt Card

components
Trending
Pro

Glare Tilt Card

components