All patterns

Payment Card Flip

A saved card turns on its vertical axis to put the security code where it actually lives.

commercepremiumelegantinteraction · finite · intermediate · ~0.5s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

329 lines · react + motion only
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Payment Card Flip
 *
 * A saved card turns on its vertical axis to put the security code
 * where it lives in the real world — on the back — and turns straight
 * back when the code is in.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The card face is a literal gradient because it stands in for a
 * physical object; the chrome around it is mixed from the inherited text
 * color and reads correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `brand`, `last4`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PaymentCardFlipProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Card scheme name printed on the front. */
  brand?: string;
  /** Last four digits of the saved card. */
  last4?: string;
  /** Cardholder name. */
  holder?: string;
  /** Printed expiry. */
  expiry?: string;
  /** Label of the control that turns the card over. */
  flipLabel?: string;
  /** Label once the card is showing its back. */
  backLabel?: string;
  /** Gradient painted on the card. */
  cardFinish?: string;
  /** Fires whenever the card turns, with the face now showing. */
  onFlip?: (face: "front" | "back") => void;
};

type VariantConfig = {
  /** Spring the card turns on. */
  turn: { type: "spring"; stiffness: number; damping: number };
  /** Viewing distance in px. Closer means a more pronounced turn. */
  perspective: number;
  /** Crossfade used when the turn is replaced by a swap. */
  swapSeconds: number;
};

// A card that wobbles at the end of its turn looks like paper, not like
// a card. Every damping ratio (damping / 2√stiffness) here is at or
// above 0.9, so the face lands and stays landed. Variants differ in how
// close the viewer stands and how fast the sheet turns — never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Nearly orthographic and quick. For a wallet where cards turn all day.
  subtle: {
    turn: { type: "spring", stiffness: 340, damping: 36 },
    perspective: 2400,
    swapSeconds: 0.14,
  },
  // Enough perspective for the leading edge to read as nearer. All-purpose.
  default: {
    turn: { type: "spring", stiffness: 240, damping: 30 },
    perspective: 1100,
    swapSeconds: 0.18,
  },
  // Standing close to a single card at checkout: a slower, more physical
  // turn with the near edge sweeping past.
  playful: {
    turn: { type: "spring", stiffness: 170, damping: 26 },
    perspective: 640,
    swapSeconds: 0.22,
  },
};

/** Theme-adaptive neutral for the chrome around the card. The card
 *  finish itself stays literal — it stands in for a physical object. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const CARD_WIDTH = 268;
const CARD_HEIGHT = 168;

export default function PaymentCardFlip({
  variant = "default",
  brand = "Northwind Debit",
  last4 = "4242",
  holder = "C. RIVERA",
  expiry = "09 / 29",
  flipLabel = "Enter security code",
  backLabel = "Back to the front",
  cardFinish = "linear-gradient(132deg, #2B2E52 0%, #474C8C 46%, #7C7CF0 100%)",
  onFlip,
}: PaymentCardFlipProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [face, setFace] = useState<"front" | "back">("front");
  const flipped = face === "back";

  const turn = () => {
    const nextFace = flipped ? "front" : "back";
    setFace(nextFace);
    onFlip?.(nextFace);
  };

  const faceBase = {
    position: "absolute" as const,
    inset: 0,
    padding: 18,
    borderRadius: 16,
    background: cardFinish,
    color: "#FFFFFF",
    boxSizing: "border-box" as const,
    // The back is painted onto the reverse of the same sheet, so both
    // faces hide themselves when they are turned away from the viewer.
    backfaceVisibility: reduceMotion ? ("visible" as const) : ("hidden" as const),
    WebkitBackfaceVisibility: reduceMotion
      ? ("visible" as const)
      : ("hidden" as const),
  };

  return (
    <div style={{ width: CARD_WIDTH, display: "grid", gap: 14, fontSize: 13 }}>
      {/* Perspective on the parent, rotation on the child: without a
          perspective the turn is a flat squash rather than a card. */}
      <div style={{ perspective: cfg.perspective, height: CARD_HEIGHT }}>
        <motion.div
          initial={false}
          // Reduced motion keeps the two faces and the control, and drops
          // the turn: the back arrives by crossfade instead.
          animate={{ rotateY: reduceMotion ? 0 : flipped ? 180 : 0 }}
          transition={reduceMotion ? { duration: 0 } : cfg.turn}
          style={{
            position: "relative",
            width: "100%",
            height: "100%",
            transformStyle: "preserve-3d",
          }}
        >
          {/* Front */}
          <motion.div
            initial={false}
            animate={{ opacity: reduceMotion && flipped ? 0 : 1 }}
            transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
            aria-hidden={flipped}
            style={{
              ...faceBase,
              display: "flex",
              flexDirection: "column",
              justifyContent: "space-between",
            }}
          >
            {/* Finish artwork: arcs sweeping in from past the corner,
                plus the contactless mark. Printed on the object, so the
                white stays literal. The svg is positioned, so the three
                content rows carry position:relative to paint above it;
                its own border-radius clips the arcs at the corner. */}
            <svg
              width="100%"
              height="100%"
              viewBox="0 0 268 168"
              fill="none"
              aria-hidden
              preserveAspectRatio="none"
              style={{
                position: "absolute",
                inset: 0,
                borderRadius: 16,
                pointerEvents: "none",
              }}
            >
              <circle cx="300" cy="196" r="88" stroke="#FFFFFF" strokeOpacity="0.1" strokeWidth="1.6" />
              <circle cx="300" cy="196" r="122" stroke="#FFFFFF" strokeOpacity="0.13" strokeWidth="1.6" />
              <circle cx="300" cy="196" r="156" stroke="#FFFFFF" strokeOpacity="0.1" strokeWidth="1.6" />
              <path
                d="M220 52a13 13 0 0 1 0 20M215.5 56a8 8 0 0 1 0 12M211.5 59.5a4.5 4.5 0 0 1 0 9"
                stroke="#FFFFFF"
                strokeOpacity="0.5"
                strokeWidth="1.6"
                strokeLinecap="round"
              />
            </svg>
            <div
              style={{ position: "relative", display: "flex", alignItems: "center", gap: 10 }}
            >
              <svg width="30" height="24" viewBox="0 0 30 24" fill="none" aria-hidden>
                <rect
                  x="0.8"
                  y="4.2"
                  width="13"
                  height="10.4"
                  rx="2.4"
                  fill="#FFFFFF"
                  fillOpacity="0.34"
                />
                <path
                  d="M4.6 4.2v10.4M9.9 4.2v10.4M0.8 9.4h13"
                  stroke="#FFFFFF"
                  strokeOpacity="0.5"
                  strokeWidth="0.9"
                />
              </svg>
              <span
                style={{
                  marginLeft: "auto",
                  fontSize: 11.5,
                  fontWeight: 600,
                  letterSpacing: 0.6,
                  opacity: 0.8,
                }}
              >
                {brand}
              </span>
            </div>

            <div
              style={{
                position: "relative",
                fontSize: 17,
                letterSpacing: 2.4,
                fontVariantNumeric: "tabular-nums",
              }}
            >
              <span style={{ opacity: 0.62 }}>{"•••• ".repeat(3)}</span>
              {last4}
            </div>

            <div style={{ position: "relative", display: "flex", alignItems: "flex-end", gap: 12 }}>
              <span style={{ fontSize: 11, letterSpacing: 0.8, opacity: 0.85 }}>
                {holder}
              </span>
              <span
                style={{
                  marginLeft: "auto",
                  fontSize: 11,
                  letterSpacing: 0.6,
                  opacity: 0.7,
                  fontVariantNumeric: "tabular-nums",
                }}
              >
                {expiry}
              </span>
            </div>
          </motion.div>

          {/* Back */}
          <motion.div
            initial={false}
            animate={{ opacity: reduceMotion && !flipped ? 0 : 1 }}
            transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
            aria-hidden={!flipped}
            style={{
              ...faceBase,
              padding: 0,
              // Painted on the reverse of the same sheet.
              rotateY: reduceMotion ? 0 : 180,
              display: "flex",
              flexDirection: "column",
              gap: 14,
              paddingTop: 20,
            }}
          >
            <div
              aria-hidden
              style={{
                height: 34,
                background: "rgba(0, 0, 0, 0.45)",
              }}
            />
            <div style={{ padding: "0 18px", display: "grid", gap: 8 }}>
              <label
                htmlFor="vibary-cvc"
                style={{ fontSize: 10.5, letterSpacing: 0.7, opacity: 0.7 }}
              >
                SECURITY CODE
              </label>
              <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
                <input
                  id="vibary-cvc"
                  type="text"
                  inputMode="numeric"
                  maxLength={4}
                  placeholder="123"
                  tabIndex={flipped ? 0 : -1}
                  style={{
                    width: 76,
                    height: 34,
                    padding: "0 10px",
                    fontSize: 14,
                    fontFamily: "inherit",
                    letterSpacing: 2,
                    color: "inherit",
                    background: "rgba(255, 255, 255, 0.16)",
                    border: "1px solid rgba(255, 255, 255, 0.28)",
                    borderRadius: 8,
                    outline: "none",
                    boxSizing: "border-box",
                  }}
                />
                <span style={{ fontSize: 11, opacity: 0.7, lineHeight: 1.35 }}>
                  Three digits, printed
                  <br />
                  beside the signature strip
                </span>
              </div>
            </div>
          </motion.div>
        </motion.div>
      </div>

      <button
        type="button"
        onClick={turn}
        style={{
          height: 38,
          fontSize: 13,
          fontWeight: 600,
          fontFamily: "inherit",
          color: "inherit",
          background: tone(8),
          border: `1px solid ${tone(14)}`,
          borderRadius: 10,
          cursor: "pointer",
        }}
      >
        {flipped ? backLabel : flipLabel}
      </button>
    </div>
  );
}

About this pattern

Asking for a security code in a plain field makes the shopper picture the card and translate. Turning the card instead removes the translation: the sheet rotates about its vertical axis under a real perspective, the back arrives with its stripe and the code field beside it, and turning back returns the summary. Both faces are painted on one sheet with backface visibility off, so nothing ever shows through. Reduced motion keeps both faces and the control and swaps them with a crossfade — the code still gets asked for, just without the rotation.

Security code entry at checkoutSaved card in a wallet screenCard details review before payingBilling settings card management

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • BillingLast 30 days
    Invoice 4821$169.65Paid
    Invoice 4809$412.00Paid
    Invoice 4794$88.20Due Jun 30
    Invoice 4780$1,204.00Paid
    Billing page

    A stored card turns over to reveal the number and details on its reverse.

Related patterns