Components

Morphing Checkout Flow

A three-step card payment where the container appears to animate height: auto - the active step is measured and the box eases to its px while steps slide past each other. The field re-masks on every keystroke (4-4-4-4, or 4-6-5 the moment it detects an Amex) yet the caret never jumps: it is put back by counting the digits before it, the only characters you actually own. Full numbers are checked against Luhn, brand detection cross-fades the logo on a live card that flips in 3D when you focus the CVC (an Amex never flips - its code lives on the front), and paying FLIPs the button's width from measured px into a spinner circle, then a drawn check. Tick Decline the payment and the same circle lands on a red ✕ instead, shakes once, and eases back for another try.

Step 1 of 3: card details

Step: card · Brand: unknown · Number: empty · Side: front

Install

npx moumenlab add morphing-checkout

For AI

Open .md

Usage

"use client";

import MorphingCheckout from "./morphing-checkout";

export default function MorphingCheckoutExample() {
  return (
    <div className="flex flex-col items-center gap-10">
      {/* Wire onPay to your real charge - return "decline" to fail, anything
          else to succeed. Async is fine (the button spins while it resolves). */}
      <MorphingCheckout
        price="$149.00"
        onPay={async ({ number }) => {
          const res = await fetch("/api/charge", { method: "POST", body: JSON.stringify({ number }) });
          return res.ok ? "success" : "decline";
        }}
      />

      {/* indicator="bar" swaps the segmented tabs for a loading bar that is also
          the tabs; outcome forces the demo verdict without an onPay. */}
      <MorphingCheckout indicator="bar" outcome="decline" />
    </div>
  );
}

Story

  1. An enhancement, not a new idea

    This one already existed. I built a card checkout inside a design system, and it was correct: the right fields, the right validation, the right order. It just moved like a form. Every step replaced the one before it and the box snapped to its new size. So this build keeps the three steps and the fields exactly as they shipped, and rebuilds everything about how it moves.

  2. The constraint was staying small

    The rule I set before touching the motion: it has to stay small. One card, capped at 22rem, no full-page layout, nothing that depends on the space around it. That is what lets the same component sit in a page or drop into a popup with no second version to maintain. Small also decided the hard part: at this size the three steps cannot be three screens.

  3. One box that changes its own height

    So the container fakes height: auto. The active panel is measured (useLayoutEffect plus a ResizeObserver) and the box eases to that exact px, while the outgoing step blurs out and the incoming fields cascade in, direction-aware. The box is the only thing that resizes, which is also what makes it safe in a popup: nothing around it reflows.

  4. Formal, so progress is a bar

    A payment is not a place to be playful. Tabs in a pill are fine for a settings panel, but here they read casual, so there is a second indicator: the same three labels sit over a thin track that fills a third, two thirds, all of it, and turns green when the charge lands. It is the step indicator and the progress bar at once, and nothing decorative moves anywhere in the card.

  5. Errors have to feel like errors

    A red border is information. It is not a feeling, and money is exactly where a mistake should feel like one. So failures shake: 4px, 320ms, once, never repeating. What matters is that the shake is scoped to what is wrong. The number field shakes on its own the moment a complete number fails Luhn, before you reach for Continue. The primary button shakes when the step won't validate, and focus jumps to the first bad field.

  6. The button carries the charge

    Paying is the one wait in the flow, so the button becomes its own progress: its measured width collapses into a 2.75rem circle while the label cross-fades to a spinner, then the circle answers. Success draws a check on green. A decline draws a red ✕, shakes once, then eases back out to the full-width Pay button with the reason underneath, so the retry is exactly where your cursor already is. No dead end, no new screen.

References

  • Luhn algorithm: the checksum every real card number satisfies: why a number can be the right length and still wrong, and the reason the field can reject it live instead of waiting for the server.
  • prefers-reduced-motion (MDN): the escape hatch the whole component is wired to: the height morph, the slides and the shakes all switch off, and the three steps still work as a plain form.