Gift Card Apply
A single sheen crosses the card while the balance eases down and the amount due settles to zero.
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 · Gift Card Apply
*
* Applying a gift code sends a single sheen across the card and eases
* the balance down to what is left, while the amount due settles to
* zero. The figures change value without changing size: they keep their
* baseline, their weight and their tabular width the whole way, because
* money that springs is money that looks unreliable.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color; the card face is a
* CSS gradient with a drawn ribbon standing in for artwork.
* Works with zero props; tune via `variant`, `cardBalance`, `amountDue`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type GiftCardApplyProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Value sitting on the gift card before it is applied. */
cardBalance?: number;
/** What the order costs before the gift card. */
amountDue?: number;
/** Fires with the amount the gift card covered. */
onApply?: (covered: number) => void;
};
type VariantConfig = {
/** Seconds for the balance to ease to its new value. */
count: number;
/** Seconds for the sheen to cross the card. */
sheen: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** Seconds for the applied row to open. */
row: number;
};
// Quality rule: the only spring here moves the applied row and the
// status chip, both at or above a 0.8 damping ratio. The amounts are
// eased, not sprung — a figure that overshoots shows a number the
// customer was never charged, however briefly.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Fast, no sheen worth noticing. For a checkout the customer repeats.
subtle: {
count: 0.42,
sheen: 0.5,
spring: { type: "spring", stiffness: 540, damping: 42 },
row: 0.2,
},
// The sheen reads, the balance eases down. All-purpose.
default: {
count: 0.7,
sheen: 0.72,
spring: { type: "spring", stiffness: 400, damping: 34 },
row: 0.28,
},
// A slower count for a gifting flow, where the card is the occasion.
playful: {
count: 1,
sheen: 0.95,
spring: { type: "spring", stiffness: 340, damping: 31 },
row: 0.34,
},
};
const ACCENT = "#2F9E6E";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** The card face stands in for artwork, so it stays literal — a gift card
* is a printed object, not a surface that should follow the page theme. */
const CARD_ART =
"linear-gradient(135deg, #2B4C7E 0%, #5B4B94 48%, #A0507E 100%)";
const money = (value: number) =>
`$${value.toLocaleString("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
/**
* Eases a number toward a target with requestAnimationFrame. Deliberately
* not a spring: an amount of money must never overshoot into a value the
* customer was not charged. Pass `duration: 0` to jump.
*/
function useCountTo(target: number, duration: number) {
const [value, setValue] = useState(target);
const currentRef = useRef(target);
useEffect(() => {
// Reduced motion (duration 0) never animates: the target is rendered
// directly below, and the ref is kept honest here so a later run
// starts from the right place.
if (duration <= 0) {
currentRef.current = target;
return;
}
const from = currentRef.current;
if (from === target) return;
let frame = 0;
let startedAt = 0;
const tick = (now: number) => {
if (!startedAt) startedAt = now;
const progress = Math.min(1, (now - startedAt) / (duration * 1000));
const eased = 1 - Math.pow(1 - progress, 3);
const next = from + (target - from) * eased;
currentRef.current = next;
setValue(next);
if (progress < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [target, duration]);
return duration <= 0 ? target : value;
}
export default function GiftCardApply({
variant = "default",
cardBalance = 50,
amountDue = 37.6,
onApply,
}: GiftCardApplyProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [code, setCode] = useState("GIFT-4K2P-90XU");
const [applied, setApplied] = useState(false);
const covered = Math.min(cardBalance, amountDue);
const countDuration = reduceMotion ? 0 : cfg.count;
const balance = useCountTo(applied ? cardBalance - covered : cardBalance, countDuration);
const due = useCountTo(applied ? amountDue - covered : amountDue, countDuration);
const apply = () => {
if (applied || code.trim().length === 0) return;
setApplied(true);
onApply?.(covered);
};
return (
<div
style={{
width: 342,
padding: 16,
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
fontFamily: "inherit",
boxSizing: "border-box",
}}
>
<motion.div
animate={{ y: applied && !reduceMotion ? -2 : 0 }}
transition={cfg.spring}
style={{
position: "relative",
height: 106,
padding: 14,
borderRadius: 14,
background: CARD_ART,
color: "#FFFFFF",
overflow: "hidden",
boxSizing: "border-box",
}}
>
{/* Wrap ribbon, printed on the card: two broad bands crossing
off-center with a bow over the knot. The crossing must not sit
at dead center — centered thin strokes read as a reticle, not
as wrapping. */}
<svg
width="100%"
height="100%"
viewBox="0 0 300 106"
fill="none"
aria-hidden
preserveAspectRatio="none"
style={{ position: "absolute", inset: 0 }}
>
<rect x="190" width="24" height="106" fill="#FFFFFF" fillOpacity="0.15" />
<rect y="36" width="300" height="18" fill="#FFFFFF" fillOpacity="0.22" />
<path
d="M202 45 C 194 34 182 29 176 34 C 171 39 184 49 202 45 Z"
fill="#FFFFFF"
fillOpacity="0.22"
/>
<path
d="M202 45 C 210 34 222 29 228 34 C 233 39 220 49 202 45 Z"
fill="#FFFFFF"
fillOpacity="0.22"
/>
<circle cx="202" cy="45" r="4.6" fill="#FFFFFF" fillOpacity="0.32" />
</svg>
<div style={{ position: "relative" }}>
<div
style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: "0.14em",
textTransform: "uppercase",
opacity: 0.75,
}}
>
Gift card
</div>
<div
style={{
marginTop: 30,
fontSize: 11,
opacity: 0.7,
}}
>
Balance
</div>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 10,
}}
>
<span
style={{
fontSize: 22,
fontWeight: 680,
letterSpacing: "-0.02em",
fontVariantNumeric: "tabular-nums",
}}
>
{money(balance)}
</span>
<span
style={{
fontSize: 11,
letterSpacing: "0.08em",
opacity: 0.7,
fontVariantNumeric: "tabular-nums",
}}
>
···· 90XU
</span>
</div>
</div>
{/* One pass, never a loop: the sheen is a receipt for the tap,
not decoration. It is a moving highlight over artwork, so the
white stays literal. */}
<AnimatePresence>
{applied && !reduceMotion && (
<motion.div
key="sheen"
aria-hidden
initial={{ x: "-130%" }}
animate={{ x: "130%" }}
exit={{ opacity: 0 }}
transition={{ duration: cfg.sheen, ease: "easeOut" }}
style={{
position: "absolute",
inset: "-20% -40%",
background:
"linear-gradient(104deg, rgba(255,255,255,0) 38%, rgba(255,255,255,0.38) 50%, rgba(255,255,255,0) 62%)",
pointerEvents: "none",
}}
/>
)}
</AnimatePresence>
</motion.div>
<div style={{ display: "flex", gap: 8, marginTop: 13 }}>
<input
value={code}
onChange={(event) => setCode(event.target.value)}
disabled={applied}
aria-label="Gift card code"
spellCheck={false}
style={{
flex: 1,
minWidth: 0,
padding: "9px 11px",
fontSize: 12.5,
fontFamily: "inherit",
letterSpacing: "0.04em",
borderRadius: 10,
border: `1px solid ${tone(13)}`,
background: tone(5),
color: "inherit",
opacity: applied ? 0.55 : 1,
boxSizing: "border-box",
}}
/>
<button
type="button"
onClick={apply}
disabled={applied}
style={{
padding: "9px 15px",
fontSize: 12.5,
fontWeight: 650,
fontFamily: "inherit",
borderRadius: 10,
border: "none",
background: applied ? tone(11) : ACCENT,
color: applied ? "inherit" : "#FFFFFF",
opacity: applied ? 0.6 : 1,
cursor: applied ? "default" : "pointer",
whiteSpace: "nowrap",
}}
>
{applied ? "Applied" : "Apply"}
</button>
</div>
<div
style={{
marginTop: 13,
paddingTop: 12,
borderTop: `1px solid ${tone(10)}`,
display: "grid",
gap: 7,
fontSize: 12,
}}
>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={{ opacity: 0.6 }}>Order total</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{money(amountDue)}
</span>
</div>
<AnimatePresence initial={false}>
{applied && (
<motion.div
key="covered"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
height: {
duration: reduceMotion ? 0 : cfg.row,
ease: "easeOut",
},
opacity: { duration: reduceMotion ? 0.14 : cfg.row * 0.8 },
}}
style={{ overflow: "hidden", color: ACCENT, fontWeight: 600 }}
>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span>Gift card applied</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>
−{money(covered)}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 13.5,
fontWeight: 660,
}}
>
<span>Due today</span>
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{money(due)}
</span>
</div>
</div>
</div>
);
}About this pattern
Redeeming a stored balance at checkout. The sheen is a receipt for the tap rather than decoration, so it makes exactly one pass and never loops. The figures ease rather than spring: an amount of money that overshoots shows a value the customer was never charged, however briefly, so the balance and the amount due are tweened with tabular figures that keep their width, weight and baseline the whole way. The credit row opens beneath the order total as the count runs, which is what ties the two numbers together.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
Redeeming a card animates the stored balance down to its new value on the card face.
Related patterns
- Variant Swatch SwitchChoosing a colorway crossfades the product while one selection ring travels to the swatch.
- Address AutocompleteChoosing a suggestion closes the list from the bottom up and fills the fields in reading order.
- Upsell Slide InA complementary item opens the layout beneath the cart instead of covering it.