All patterns

Coupon Apply

A valid code folds into a chip, a discount line opens in the summary, and the total falls.

commerceenergeticfriendlyinteraction · finite · intermediate · ~0.9s
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.

417 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Coupon Apply
 *
 * A valid code stops being something you typed and becomes something you
 * have: the field folds into a chip, a discount line opens in the
 * summary, and the total rolls down to what you now owe.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `code`, `discount`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CouponApplyProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Code prefilled in the field. */
  code?: string;
  /** Formatted subtotal. */
  subtotal?: string;
  /** Formatted amount the code takes off. */
  discount?: string;
  /** Formatted total before the code. */
  totalBefore?: string;
  /** Formatted total after it. */
  totalAfter?: string;
  /** Label of the apply control. */
  applyLabel?: string;
  /** How long the code is checked for, in ms. */
  checkMs?: number;
  /** Accent for the apply control. */
  accent?: string;
  /** Fires once the code is on the order. */
  onApplied?: (code: string) => void;
};

type VariantConfig = {
  /** Spring the rolling total rides. */
  roll: { type: "spring"; stiffness: number; damping: number };
  /** How long the discount line takes to open. */
  openSeconds: number;
  /** Crossfade for the field folding into the chip. */
  swapSeconds: number;
};

// The number people care about is the total, and it has to be legible
// the instant it lands: damping ratios (damping / 2√stiffness) sit at or
// above 0.88. The discount line opens on an eased tween, because a row
// that springs into a summary reads as rubber.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Brisk, for a checkout where codes are applied and swapped often.
  subtle: {
    roll: { type: "spring", stiffness: 620, damping: 48 },
    openSeconds: 0.2,
    swapSeconds: 0.14,
  },
  // Enough time to watch the money move. All-purpose.
  default: {
    roll: { type: "spring", stiffness: 440, damping: 38 },
    openSeconds: 0.3,
    swapSeconds: 0.2,
  },
  // A longer roll for a single-page checkout where the saving is the
  // headline.
  playful: {
    roll: { type: "spring", stiffness: 300, damping: 31 },
    openSeconds: 0.4,
    swapSeconds: 0.24,
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one.
 *  The saving color stays literal — it carries meaning. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const SAVING = "#10B981";

type Phase = "idle" | "checking" | "applied";

export default function CouponApply({
  variant = "default",
  code = "SPRING20",
  subtotal = "$248.00",
  discount = "−$49.60",
  totalBefore = "$248.00",
  totalAfter = "$198.40",
  applyLabel = "Apply",
  checkMs = 620,
  accent = "#7C7CF0",
  onApplied,
}: CouponApplyProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [phase, setPhase] = useState<Phase>("idle");
  const [entered, setEntered] = useState(code);
  const applied = phase === "applied";

  const onAppliedRef = useRef(onApplied);
  useEffect(() => {
    onAppliedRef.current = onApplied;
  }, [onApplied]);

  useEffect(() => {
    if (phase !== "checking") return;
    const timer = setTimeout(() => {
      setPhase("applied");
      onAppliedRef.current?.(entered);
    }, checkMs);
    return () => clearTimeout(timer);
  }, [phase, checkMs, entered]);

  const total = applied ? totalAfter : totalBefore;
  // The total falls, so the incoming figure comes down from above and
  // pushes the old one out of the bottom of the slot.
  const travel = reduceMotion ? 0 : 26;

  return (
    <div
      style={{
        width: 274,
        padding: "14px 16px 16px",
        borderRadius: 14,
        background: tone(5),
        border: `1px solid ${tone(10)}`,
        fontSize: 13,
      }}
    >
      {/* Field and chip share one row. The field folds away and the chip
          takes its place, so the summary below never jumps. */}
      <div style={{ display: "grid", minHeight: 36 }}>
        <motion.div
          initial={false}
          animate={{ opacity: applied ? 0 : 1 }}
          transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
          aria-hidden={applied}
          style={{
            gridArea: "1 / 1",
            display: "flex",
            gap: 8,
            // Without this the row's min-content (driven by the input's
            // intrinsic ~20ch width) sets the grid track, pushing the
            // apply control past the panel edge.
            minWidth: 0,
            // The faded-out layer is still in the box; it must not catch
            // the presses meant for the layer above it.
            pointerEvents: applied ? "none" : "auto",
          }}
        >
          <input
            type="text"
            value={entered}
            onChange={(event) => setEntered(event.target.value.toUpperCase())}
            placeholder="Promo code"
            aria-label="Promo code"
            tabIndex={applied ? -1 : 0}
            style={{
              flex: 1,
              minWidth: 0,
              height: 36,
              padding: "0 11px",
              fontSize: 12.5,
              fontWeight: 600,
              letterSpacing: 0.6,
              fontFamily: "inherit",
              color: "inherit",
              background: tone(6),
              border: `1px solid ${tone(12)}`,
              borderRadius: 9,
              outline: "none",
              boxSizing: "border-box",
            }}
          />
          <button
            type="button"
            onClick={() => phase === "idle" && setPhase("checking")}
            disabled={phase !== "idle"}
            tabIndex={applied ? -1 : 0}
            style={{
              width: 76,
              height: 36,
              flexShrink: 0,
              display: "grid",
              placeItems: "center",
              fontSize: 12.5,
              fontWeight: 600,
              fontFamily: "inherit",
              color: "#FFFFFF",
              background: accent,
              border: 0,
              borderRadius: 9,
              cursor: phase === "idle" ? "pointer" : "default",
            }}
          >
            {/* The two labels sit in one cell so the control cannot
                change width while it is being pressed. */}
            <motion.span
              initial={false}
              animate={{ opacity: phase === "idle" ? 1 : 0 }}
              transition={{ duration: 0.14, ease: "easeOut" }}
              style={{ gridArea: "1 / 1" }}
            >
              {applyLabel}
            </motion.span>
            <motion.span
              initial={false}
              animate={{ opacity: phase === "checking" ? 1 : 0 }}
              transition={{ duration: 0.14, ease: "easeOut" }}
              style={{ gridArea: "1 / 1" }}
            >
              Checking
            </motion.span>
          </button>
        </motion.div>

        <motion.div
          initial={false}
          animate={{
            opacity: applied ? 1 : 0,
            y: applied || reduceMotion ? 0 : 6,
          }}
          transition={{
            duration: cfg.swapSeconds,
            ease: "easeOut",
            delay: applied && !reduceMotion ? cfg.swapSeconds * 0.5 : 0,
          }}
          aria-hidden={!applied}
          style={{
            gridArea: "1 / 1",
            display: "flex",
            alignItems: "center",
            alignSelf: "center",
            pointerEvents: applied ? "auto" : "none",
          }}
        >
          <span
            style={{
              display: "inline-flex",
              alignItems: "center",
              gap: 7,
              height: 30,
              padding: "0 6px 0 10px",
              borderRadius: 999,
              fontSize: 12,
              fontWeight: 600,
              letterSpacing: 0.4,
              color: SAVING,
              background: `color-mix(in srgb, ${SAVING} 13%, transparent)`,
            }}
          >
            <svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
              <path
                d="M3.4 8.4 6.3 11.3 12.6 5"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
            {entered}
            <button
              type="button"
              onClick={() => setPhase("idle")}
              aria-label={`Remove code ${entered}`}
              tabIndex={applied ? 0 : -1}
              style={{
                width: 20,
                height: 20,
                display: "grid",
                placeItems: "center",
                padding: 0,
                border: 0,
                borderRadius: 999,
                background: "transparent",
                color: "inherit",
                cursor: "pointer",
              }}
            >
              <svg width="9" height="9" viewBox="0 0 12 12" fill="none" aria-hidden>
                <path
                  d="M2.6 2.6 9.4 9.4M9.4 2.6 2.6 9.4"
                  stroke="currentColor"
                  strokeWidth="1.7"
                  strokeLinecap="round"
                />
              </svg>
            </button>
          </span>
        </motion.div>
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "baseline",
          gap: 10,
          marginTop: 14,
        }}
      >
        <span style={{ fontSize: 12.5, opacity: 0.6 }}>Subtotal</span>
        <span
          style={{
            marginLeft: "auto",
            fontSize: 12.5,
            fontVariantNumeric: "tabular-nums",
          }}
        >
          {subtotal}
        </span>
      </div>

      {/* A new row genuinely makes the summary taller, so height tweens —
          short and eased, with the line fading in behind the opening. */}
      <motion.div
        initial={false}
        animate={{ height: applied ? "auto" : 0, opacity: applied ? 1 : 0 }}
        transition={
          reduceMotion
            ? { duration: 0 }
            : {
                height: { duration: cfg.openSeconds, ease: [0.3, 0, 0.2, 1] },
                opacity: {
                  duration: cfg.swapSeconds,
                  ease: "easeOut",
                  delay: applied ? cfg.openSeconds * 0.35 : 0,
                },
              }
        }
        style={{ overflow: "hidden" }}
      >
        <div
          style={{
            display: "flex",
            alignItems: "baseline",
            gap: 10,
            paddingTop: 8,
          }}
        >
          <span style={{ fontSize: 12.5, color: SAVING, fontWeight: 600 }}>
            {entered}
          </span>
          <span
            style={{
              marginLeft: "auto",
              fontSize: 12.5,
              fontWeight: 600,
              color: SAVING,
              fontVariantNumeric: "tabular-nums",
            }}
          >
            {discount}
          </span>
        </div>
      </motion.div>

      <div style={{ height: 1, background: tone(12), margin: "13px 0" }} />

      <div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
        <span style={{ fontSize: 13, fontWeight: 600 }}>Total</span>
        {/* The total rolls: it travels down and crossfades, and it holds
            one type size the whole way. */}
        <span
          style={{
            marginLeft: "auto",
            display: "grid",
            justifyItems: "end",
            height: 26,
            overflow: "hidden",
          }}
        >
          <AnimatePresence initial={false}>
            <motion.span
              key={total}
              initial={{ y: -travel, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: travel, opacity: 0 }}
              transition={{
                y: reduceMotion ? { duration: 0 } : cfg.roll,
                opacity: { duration: cfg.swapSeconds, ease: "easeOut" },
              }}
              style={{
                gridArea: "1 / 1",
                fontSize: 21,
                fontWeight: 650,
                lineHeight: "26px",
                letterSpacing: -0.3,
                fontVariantNumeric: "tabular-nums",
              }}
            >
              {total}
            </motion.span>
          </AnimatePresence>
        </span>
      </div>

      <span
        aria-live="polite"
        style={{
          position: "absolute",
          width: 1,
          height: 1,
          overflow: "hidden",
          clipPath: "inset(50%)",
          whiteSpace: "nowrap",
        }}
      >
        {applied ? `${entered} applied. New total ${totalAfter}.` : ""}
      </span>
    </div>
  );
}

About this pattern

Three things have to happen the moment a code is accepted, and they have to happen in an order the eye can follow. The field crossfades into a chip in the same row, so nothing below it jumps; a discount line opens under the subtotal on a short eased height tween; and the total rolls downward — the new figure arriving from above and pushing the old one out of the slot, at a constant type size. Removing the code reverses all three. The chip carries its own remove control, which means the code stops being text you have to retype and becomes an object you can take off.

Promo code at checkoutReferral credit applied to a cartStudent or member discountLoyalty reward redeemed

Where it shows up

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

  • 10:15
    Payment
    Card number4242 4242 4242 4242Name on cardN. Bergström
    Expiry04 / 28CVC•••
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Pay $169.65
    Checkout

    An accepted code becomes a removable chip and adds its own line to the summary.

Related patterns