Coupon Apply
A valid code folds into a chip, a discount line opens in the summary, and the total falls.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
An accepted code becomes a removable chip and adds its own line to the summary.
Related patterns
- Add to Cart FlyThe product tile arcs from the card into the cart glyph, and the badge ticks over on arrival.
- Wishlist Heart FillThe heart fills from its own center on one soft settle, and drains back out on a plain ease.
- Cart Quantity StepperStepping the quantity rolls the count and both totals in the direction of the change.