Flipping Payment Card with Live Typing Form

A checkout block with a drawn payment card beside a four-field form. Each character typed into the form drops into place on the card, a highlight slides between the number, name and expiry as the fields change, and the card turns over on a spring when the security code field is focused. A self-playing demo types a sample card until a visitor touches a field, then it clears and hands over. On phones the card sits above the form at full width.

Code

import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";

const order = { shop: "Moss & Kiln", item: "Stoneware pour-over set", delivery: "Arrives Tuesday", total: "$48.00" };
const bank = "Fernbank";
const network = "tally";
/** What the self-playing demo types. It stops for good as soon as a visitor uses a field. */
const demo = { number: "7204118350964421", name: "Ines Calloway", expiry: "0829", cvc: "614" };
/** Milliseconds per keystroke in the demo. */
const SPEED = { number: 50, name: 40, expiry: 60, cvc: 150 };

type Field = "number" | "name" | "expiry" | "cvc";

const fieldClass = (on: boolean) =>
  `h-12 w-full rounded-xl border bg-white px-4 text-base text-stone-900 placeholder:text-stone-400 transition focus:border-emerald-700 focus:outline-none focus:ring-4 focus:ring-emerald-700/15 ${on ? "border-emerald-700 ring-4 ring-emerald-700/15" : "border-stone-300"}`;

/** One character cell on the card. A typed character drops in; an empty cell shows a dim dot. */
function Cell({ ch, still, hint = "•" }: { ch?: string; still: boolean; hint?: string }) {
  return (
    <span className="relative inline-block w-[0.64em] text-center">
      {ch ? (
        <motion.span key={ch} className="inline-block" initial={still ? false : { y: "0.5em", opacity: 0, filter: "blur(4px)" }} animate={{ y: 0, opacity: 1, filter: "blur(0px)" }} transition={{ type: "spring", stiffness: 520, damping: 26 }}>
          {ch}
        </motion.span>
      ) : (
        <span className="text-white/35">{hint}</span>
      )}
    </span>
  );
}

export default function FlippingPaymentCardWithLiveTypingForm() {
  const reduce = useReducedMotion();
  const still = Boolean(reduce);
  const rootRef = useRef<HTMLElement>(null);
  const inView = useInView(rootRef, { amount: 0.3 });
  const [number, setNumber] = useState("");
  const [name, setName] = useState("");
  const [expiry, setExpiry] = useState("");
  const [cvc, setCvc] = useState("");
  const [focus, setFocus] = useState<Field | null>(null);
  const [manual, setManual] = useState(false);
  const [paid, setPaid] = useState(false);

  // The demo: a list of timed steps played in a loop.
  useEffect(() => {
    if (manual) return;
    if (still) {
      setNumber(demo.number);
      setName(demo.name);
      setExpiry(demo.expiry);
      setCvc(demo.cvc);
      setFocus(null);
      return;
    }
    if (!inView) return;
    const steps: Array<[number, () => void]> = [];
    steps.push([300, () => { setNumber(""); setName(""); setExpiry(""); setCvc(""); setFocus("number"); }]);
    for (let i = 1; i <= demo.number.length; i++) steps.push([SPEED.number, () => setNumber(demo.number.slice(0, i))]);
    steps.push([140, () => setFocus("name")]);
    for (let i = 1; i <= demo.name.length; i++) steps.push([SPEED.name, () => setName(demo.name.slice(0, i))]);
    steps.push([120, () => setFocus("expiry")]);
    for (let i = 1; i <= demo.expiry.length; i++) steps.push([SPEED.expiry, () => setExpiry(demo.expiry.slice(0, i))]);
    steps.push([180, () => setFocus("cvc")]);
    for (let i = 1; i <= demo.cvc.length; i++) steps.push([i === 1 ? 480 : SPEED.cvc, () => setCvc(demo.cvc.slice(0, i))]);
    steps.push([2000, () => setFocus(null)]);
    steps.push([2600, () => undefined]);
    let i = 0;
    let timer = 0;
    const run = () => {
      const [wait, act] = steps[i % steps.length];
      timer = window.setTimeout(() => {
        act();
        i += 1;
        run();
      }, wait);
    };
    run();
    return () => window.clearTimeout(timer);
  }, [manual, still, inView]);

  const take = (field: Field) => {
    if (!manual) {
      setManual(true);
      setNumber("");
      setName("");
      setExpiry("");
      setCvc("");
    }
    setPaid(false);
    setFocus(field);
  };
  const flipped = focus === "cvc";
  const complete = number.length === 16 && name.trim().length > 1 && expiry.length === 4 && cvc.length === 3;
  const groups = [0, 1, 2, 3].map((g) => number.slice(g * 4, g * 4 + 4));
  const shownExpiry = expiry.slice(0, 2) + (expiry.length > 2 ? `/${expiry.slice(2)}` : "");
  const ring = (field: Field) =>
    focus === field ? <motion.span layoutId="flipping-payment-card-with-live-typing-form-ring" aria-hidden="true" className="absolute -inset-x-2.5 -inset-y-1.5 rounded-lg border border-white/50 bg-white/10" transition={still ? { duration: 0 } : { type: "spring", stiffness: 380, damping: 32 }} /> : null;

  return (
    <section ref={rootRef} className="overflow-hidden bg-stone-100 px-5 py-14 text-stone-900 sm:px-6 sm:py-20">
      <div className="mx-auto grid max-w-5xl items-center gap-10 lg:grid-cols-2 lg:gap-16">
        <div>
          <motion.div initial={still ? false : { opacity: 0, y: 14 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}>
            <p className="text-sm font-semibold uppercase tracking-widest text-emerald-800">{order.shop} checkout</p>
            <h2 className="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">Pay for your order</h2>
            <p className="mt-4 max-w-md text-base leading-7 text-stone-600">
              {order.item}. {order.delivery}. The card on screen fills in as you type, and turns over when you reach the security code.
            </p>
          </motion.div>

          <motion.div
            className="relative mx-auto mt-10 w-full max-w-[430px] lg:mx-0"
            style={{ perspective: 1400 }}
            initial={still ? false : { opacity: 0, y: 40, rotateZ: -6, scale: 0.9 }}
            animate={{ opacity: 1, y: 0, rotateZ: 0, scale: 1 }}
            transition={{ type: "spring", stiffness: 140, damping: 16, delay: 0.05 }}
          >
            <span aria-hidden="true" className="absolute inset-x-8 bottom-0 top-1/2 translate-y-6 rounded-full bg-emerald-900/30 blur-2xl" />
            <motion.div
              role="img"
              aria-label={`${bank} card preview. Number ${number ? groups.filter(Boolean).join(" ") : "not entered"}, name ${name || "not entered"}, expires ${shownExpiry || "not entered"}.`}
              className="relative aspect-[1.586] w-full"
              style={{ transformStyle: "preserve-3d" }}
              initial={false}
              animate={{ rotateY: flipped ? 180 : 0 }}
              transition={still ? { duration: 0 } : { type: "spring", stiffness: 150, damping: 17, mass: 0.9 }}
            >
              {/* Front */}
              <div className="absolute inset-0 overflow-hidden rounded-[22px] bg-gradient-to-br from-emerald-700 via-emerald-900 to-teal-950 text-white shadow-xl shadow-emerald-950/30 ring-1 ring-inset ring-white/15" style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden" }}>
                <svg viewBox="0 0 400 252" preserveAspectRatio="none" aria-hidden="true" className="absolute inset-0 h-full w-full">
                  <g fill="none" stroke="#a7f3d0" strokeOpacity="0.14">
                    <path d="M-20 210 C80 150 150 250 250 180 S380 110 430 150" />
                    <path d="M-20 180 C80 120 150 220 250 150 S380 80 430 120" />
                    <path d="M-20 150 C80 90 150 190 250 120 S380 50 430 90" />
                    <path d="M-20 120 C80 60 150 160 250 90 S380 20 430 60" />
                    <path d="M-20 90 C80 30 150 130 250 60 S380 -10 430 30" />
                    <path d="M-20 60 C80 0 150 100 250 30 S380 -40 430 0" />
                  </g>
                </svg>
                <span aria-hidden="true" className="absolute -right-16 -top-24 h-56 w-56 rounded-full bg-lime-300/20 blur-2xl" />
                <div className="relative flex h-full flex-col justify-between p-[6%]">
                  <div className="flex items-start justify-between">
                    <p className="flex items-center gap-2 text-lg font-semibold tracking-tight">
                      <svg viewBox="0 0 24 24" aria-hidden="true" className="h-5 w-5 text-lime-300">
                        <path fill="currentColor" d="M12 2c4 3 7 6.5 7 11a7 7 0 0 1-6 6.9V22h-2v-2.1A7 7 0 0 1 5 13c0-4.5 3-8 7-11Zm0 4.200V18a5 5 0 0 0 0-11.800Z" />
                      </svg>
                      {bank}
                    </p>
                    <svg viewBox="0 0 24 24" aria-hidden="true" className="h-6 w-6 text-white/80">
                      <path d="M7 8.500a5 5 0 0 1 0 7M11 6a8.5 8.5 0 0 1 0 12M15 3.500a12 12 0 0 1 0 17" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
                    </svg>
                  </div>

                  <div>
                    <svg viewBox="0 0 44 34" aria-hidden="true" className="h-8 w-11 sm:h-9 sm:w-12">
                      <rect width="44" height="34" rx="6" fill="#facc15" />
                      <rect width="44" height="34" rx="6" fill="#fef08a" fillOpacity="0.45" />
                      <path d="M0 12h14v10H0M30 12h14M30 22h14M14 0v34M30 0v34M14 17h16" fill="none" stroke="#854d0e" strokeOpacity="0.55" strokeWidth="1.2" />
                    </svg>
                    <p className="relative mt-[5%] flex justify-between font-mono text-[19px] font-medium tracking-wider sm:text-[23px]">
                      {ring("number")}
                      {groups.map((g, gi) => (
                        <span key={gi} className="relative">
                          {[0, 1, 2, 3].map((i) => (
                            <Cell key={i} ch={g[i]} still={still} />
                          ))}
                        </span>
                      ))}
                    </p>
                  </div>

                  <div className="flex items-end justify-between gap-4">
                    <div className="relative min-w-0 flex-1">
                      {ring("name")}
                      <p className="relative text-[9px] font-medium uppercase tracking-[0.2em] text-emerald-100/80">Card holder</p>
                      <p className="relative mt-1 h-5 truncate text-sm font-medium uppercase tracking-wider">{name || <span className="text-white/35">Your name</span>}</p>
                    </div>
                    <div className="relative">
                      {ring("expiry")}
                      <p className="relative text-[9px] font-medium uppercase tracking-[0.2em] text-emerald-100/80">Expires</p>
                      <p className="relative mt-1 h-5 font-mono text-sm font-medium">
                        <Cell ch={expiry[0]} still={still} hint="M" />
                        <Cell ch={expiry[1]} still={still} hint="M" />
                        <span className={expiry.length > 2 ? "" : "text-white/35"}>/</span>
                        <Cell ch={expiry[2]} still={still} hint="Y" />
                        <Cell ch={expiry[3]} still={still} hint="Y" />
                      </p>
                    </div>
                    <p className="text-xl font-black italic leading-none tracking-tight text-lime-300">{network}</p>
                  </div>
                </div>
              </div>

              {/* Back */}
              <div className="absolute inset-0 overflow-hidden rounded-[22px] bg-gradient-to-bl from-emerald-800 via-emerald-950 to-teal-950 text-white shadow-xl shadow-emerald-950/30 ring-1 ring-inset ring-white/15" style={{ backfaceVisibility: "hidden", WebkitBackfaceVisibility: "hidden", transform: "rotateY(180deg)" }}>
                <div className="mt-[8%] h-[19%] bg-stone-950" />
                <div className="mx-[6%] mt-[6%] flex items-stretch gap-3">
                  <div className="relative h-11 flex-1 overflow-hidden rounded-md bg-stone-100">
                    <span aria-hidden="true" className="absolute inset-0 opacity-60" style={{ backgroundImage: "repeating-linear-gradient(-45deg, #d6d3d1 0 2px, transparent 2px 7px)" }} />
                    <svg viewBox="0 0 160 44" aria-hidden="true" className="absolute inset-y-0 left-2 h-full w-36">
                      <path d="M6 30c8-14 14-20 16-12 2 9-8 14-4 15 6 1 12-22 18-16 4 4-4 14 2 14 7 0 8-12 14-11 5 1 0 10 6 10 8 0 12-8 22-9 12-1 22 4 40 1" fill="none" stroke="#1c1917" strokeWidth="1.8" strokeLinecap="round" />
                    </svg>
                  </div>
                  <div className={`relative flex h-11 w-20 items-center justify-center rounded-md bg-white font-mono text-lg font-semibold tracking-widest text-stone-900 transition ${flipped ? "ring-4 ring-lime-300" : ""}`}>
                    <span className="absolute -bottom-5 right-0 whitespace-nowrap text-[9px] font-medium uppercase tracking-[0.2em] text-emerald-100/80">Security code</span>
                    {[0, 1, 2].map((i) =>
                      cvc[i] ? (
                        <motion.span key={`${i}-${cvc[i]}`} className="inline-block w-[0.7em] text-center" initial={still ? false : { scale: 0.4, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ type: "spring", stiffness: 520, damping: 22 }}>
                          {cvc[i]}
                        </motion.span>
                      ) : (
                        <span key={i} className="inline-block w-[0.7em] text-center text-stone-300">
                          {"•"}
                        </span>
                      )
                    )}
                  </div>
                </div>
                <p className="mx-[6%] mt-[9%] max-w-[62%] text-[10px] leading-4 text-emerald-100/70">Issued by {bank}. If found, post it to any branch. Use of this card means you accept the account terms.</p>
                <p className="absolute bottom-[7%] right-[6%] text-base font-black italic leading-none tracking-tight text-lime-300">{network}</p>
              </div>
            </motion.div>
          </motion.div>
        </div>

        <motion.form
          noValidate
          onSubmit={(e) => {
            e.preventDefault();
            if (complete) setPaid(true);
          }}
          className="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-stone-200 sm:p-8"
          initial={still ? false : { opacity: 0, y: 24 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1], delay: 0.12 }}
        >
          <div className="flex items-baseline justify-between gap-4 border-b border-stone-200 pb-5">
            <div>
              <p className="text-base font-semibold">{order.item}</p>
              <p className="mt-1 text-sm text-stone-600">{order.delivery}, free delivery</p>
            </div>
            <p className="text-2xl font-semibold tabular-nums tracking-tight">{order.total}</p>
          </div>

          <div className="mt-6 grid gap-5">
            <div>
              <label htmlFor="flipping-payment-card-number" className="mb-2 block text-sm font-medium text-stone-700">Card number</label>
              <input
                id="flipping-payment-card-number"
                inputMode="numeric"
                autoComplete="cc-number"
                placeholder="0000 0000 0000 0000"
                className={`${fieldClass(focus === "number")} font-mono tracking-wide`}
                value={groups.filter(Boolean).join(" ")}
                onFocus={() => take("number")}
                onBlur={() => setFocus(null)}
                onChange={(e) => setNumber(e.target.value.replace(/\D/g, "").slice(0, 16))}
              />
            </div>
            <div>
              <label htmlFor="flipping-payment-card-name" className="mb-2 block text-sm font-medium text-stone-700">Name on card</label>
              <input
                id="flipping-payment-card-name"
                autoComplete="cc-name"
                placeholder="As printed on the card"
                className={fieldClass(focus === "name")}
                value={name}
                onFocus={() => take("name")}
                onBlur={() => setFocus(null)}
                onChange={(e) => setName(e.target.value.slice(0, 24))}
              />
            </div>
            <div className="grid grid-cols-2 gap-4">
              <div>
                <label htmlFor="flipping-payment-card-expiry" className="mb-2 block text-sm font-medium text-stone-700">Expiry</label>
                <input
                  id="flipping-payment-card-expiry"
                  inputMode="numeric"
                  autoComplete="cc-exp"
                  placeholder="MM/YY"
                  className={`${fieldClass(focus === "expiry")} font-mono`}
                  value={shownExpiry}
                  onFocus={() => take("expiry")}
                  onBlur={() => setFocus(null)}
                  onChange={(e) => setExpiry(e.target.value.replace(/\D/g, "").slice(0, 4))}
                />
              </div>
              <div>
                <label htmlFor="flipping-payment-card-cvc" className="mb-2 block text-sm font-medium text-stone-700">Security code</label>
                <input
                  id="flipping-payment-card-cvc"
                  inputMode="numeric"
                  autoComplete="cc-csc"
                  placeholder="3 digits"
                  className={`${fieldClass(focus === "cvc")} font-mono`}
                  value={cvc}
                  onFocus={() => take("cvc")}
                  onBlur={() => setFocus(null)}
                  onChange={(e) => setCvc(e.target.value.replace(/\D/g, "").slice(0, 3))}
                />
              </div>
            </div>
          </div>

          <button type="submit" className="mt-7 inline-flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-emerald-800 px-6 text-base font-semibold text-white transition hover:bg-emerald-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-800 focus-visible:ring-offset-2">
            <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4">
              {paid ? <path d="M4 10.5 8 14.5 16 6" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" /> : <path fill="currentColor" d="M6 8V6a4 4 0 1 1 8 0v2h1a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h1Zm2 0h4V6a2 2 0 1 0-4 0v2Z" />}
            </svg>
            {paid ? "Payment sent" : `Pay ${order.total}`}
          </button>
          <p aria-live="polite" className="mt-3 text-center text-sm text-stone-600">
            {paid ? `Thank you. ${order.shop} has your order.` : complete ? "Everything is filled in." : "Card details are checked before anything is charged."}
          </p>
        </motion.form>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/flipping-payment-card-with-live-typing-form.json

When to use it

How to customize it

Accessibility

Questions

How does the card flip work?

The card is one element with `transform-style: preserve-3d` inside a wrapper that sets `perspective`. The front and back are stacked on top of each other with `backface-visibility: hidden`, and the back is pre-rotated 180 degrees. Animating `rotateY` on the parent from 0 to 180 turns it over, and each face hides itself when it points away.

How do the digits appear on the card as I type?

The form inputs are controlled React state. The card renders the same state as sixteen fixed-width cells. A cell with a character mounts a small motion element that springs up from below with a blur, and an empty cell shows a dim dot, so the layout never shifts as the number grows.

Does this validate or charge a card?

No. It formats the number into groups of four, limits each field to the right length and enables the confirmation once every field is full. Real validation and charging belong to your payment provider. Never send raw card numbers to your own server.

How do I stop the demo from playing?

It stops by itself the first time a field receives focus, and clears what it typed. To remove it entirely, start the `manual` state as true or delete the effect that builds the `steps` list.

Can I use it with a payment provider's hosted fields?

Yes. Hosted fields do not reveal what is typed, but they do emit focus and completion events. Use those to drive the `focus` state so the highlight and the flip still work, and show dots on the card instead of digits.

Related: Checkout Payment Form with Order Summary, Flip Cards with Details on the Back, 3D Tilt Card with Parallax Layers and Glare.

More animated cards or all free UI components. Start for free to have a full site built around it.

More in Animated Cards

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap