Reward Claim
Claiming moves the voucher out of the offer and into the wallet, whose count ticks as it lands.
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 · Reward Claim
*
* Pressing claim does not make a reward appear out of nowhere — it moves
* the one already on screen into the place rewards are kept. The voucher
* leaves the offer, travels to the wallet, and the count ticks as it
* gets there, so the number changes because something arrived in it.
*
* The voucher only ever translates and fades. It carries an amount, and
* an amount that shrinks on its way across the screen stops being money
* and starts being a particle.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Card and chrome are mixed from the inherited text color; the reward
* colour is semantic and stays literal.
* Works with zero props; tune via `variant`, `amount`, `walletCount`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type RewardClaimCollectProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Label on the wallet pill. */
walletLabel?: string;
/** How many rewards are already kept. */
walletCount?: number;
/** Face value printed on the voucher. */
amount?: string;
/** Small line under the amount. */
amountNote?: string;
/** What the reward is for. */
title?: string;
/** Terms line under the title. */
detail?: string;
/** Wording before and after claiming. */
actionLabel?: string;
claimedLabel?: string;
/** Reward colour. Semantic, so it stays literal. */
accent?: string;
/** Fires the moment the voucher reaches the wallet. */
onClaim?: () => void;
};
type VariantConfig = {
/** How long the voucher takes to reach the wallet. */
travel: number;
/** Fraction of the flight after which the count ticks. */
arriveAt: number;
/** px the incoming digit travels. */
digitRise: number;
};
// There is no spring in this file. The voucher is being put away, not
// thrown, and a wallet that recoils when something is filed in it reads
// as a slot machine. Variants change the pace of the journey only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short hop. For a rewards inbox where several can be claimed.
subtle: { travel: 0.5, arriveAt: 0.74, digitRise: 9 },
// The all-purpose setting: the journey is legible without being slow.
default: { travel: 0.72, arriveAt: 0.78, digitRise: 12 },
// A longer arc, for a one-off reward worth watching land.
playful: { travel: 0.94, arriveAt: 0.8, digitRise: 15 },
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function RewardClaimCollect({
variant = "default",
walletLabel = "Rewards",
walletCount = 3,
amount = "$10",
amountNote = "credit",
title = "Referral bonus",
detail = "Applies to your next invoice",
actionLabel = "Claim reward",
claimedLabel = "Added to your wallet",
accent = "#0F8B8D",
onClaim,
}: RewardClaimCollectProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const still = !!reduceMotion;
const [claimed, setClaimed] = useState(false);
const [arrived, setArrived] = useState(false);
// Reduced motion has no flight to wait for, so arrival is derived from
// the press rather than set from an effect.
const landed = still ? claimed : arrived;
const count = walletCount + (landed ? 1 : 0);
useEffect(() => {
if (!claimed || still) return;
const timer = setTimeout(
() => setArrived(true),
cfg.travel * cfg.arriveAt * 1000
);
return () => clearTimeout(timer);
}, [claimed, still, cfg.travel, cfg.arriveAt]);
const onClaimRef = useRef(onClaim);
useEffect(() => {
onClaimRef.current = onClaim;
}, [onClaim]);
useEffect(() => {
if (landed) onClaimRef.current?.();
}, [landed]);
return (
<div
style={{
position: "relative",
width: 300,
boxSizing: "border-box",
padding: 16,
borderRadius: 18,
border: `1px solid ${tone(12)}`,
background: tone(5),
overflow: "hidden",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12.5, fontWeight: 640 }}>Offers</span>
<span
style={{
position: "relative",
marginLeft: "auto",
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "5px 10px 5px 8px",
borderRadius: 999,
border: `1px solid ${tone(13)}`,
background: tone(6),
}}
>
{/* One acknowledgement on arrival: a tint that comes up and
goes again. Not a bounce, not a flash. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: landed && !still ? [0, 1, 0] : 0 }}
transition={{ duration: 0.72, times: [0, 0.28, 1], ease: "easeOut" }}
style={{
position: "absolute",
inset: -1,
borderRadius: 999,
background: `color-mix(in srgb, ${accent} 20%, transparent)`,
pointerEvents: "none",
}}
/>
<span
aria-hidden
style={{ position: "relative", lineHeight: 0, color: tone(58) }}
>
<svg
width="15"
height="15"
viewBox="0 0 18 18"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
>
<path d="M2.4 5.6A2 2 0 0 1 4.4 3.6h9.2a2 2 0 0 1 2 2v6.8a2 2 0 0 1-2 2H4.4a2 2 0 0 1-2-2z" />
<path d="M11.4 9h4.2" strokeLinecap="round" />
</svg>
</span>
<span
style={{
position: "relative",
fontSize: 11,
fontWeight: 600,
color: tone(62),
}}
>
{walletLabel}
</span>
{/* Fixed slot: the digit is replaced by translation and a
crossfade, never by resizing the pill around it. */}
<span
style={{
position: "relative",
width: 8,
height: 14,
overflow: "hidden",
fontSize: 11.5,
fontWeight: 700,
fontVariantNumeric: "tabular-nums",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={count}
initial={{ y: still ? 0 : cfg.digitRise, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{
y: still ? 0 : -cfg.digitRise,
opacity: 0,
transition: { duration: 0.16, ease: "easeIn" },
}}
transition={{ duration: still ? 0.16 : 0.24, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
}}
>
{count}
</motion.span>
</AnimatePresence>
</span>
</span>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginTop: 15,
marginBottom: 15,
}}
>
{/* The slot keeps its size once the voucher has gone, so nothing
in the card jumps as the reward leaves it. */}
<span style={{ position: "relative", flex: "none", width: 78, height: 52 }}>
{/* What is left behind. Fading a faint outline in where the
voucher was reads as "taken from here" rather than as a
hole in the card. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: claimed ? 1 : 0 }}
transition={{ duration: 0.3, delay: claimed ? 0.16 : 0, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
borderRadius: 11,
border: `1px dashed ${tone(14)}`,
}}
/>
<motion.span
initial={false}
animate={
claimed && !still
? { x: [0, 26, 214], y: [0, -9, -54], opacity: [1, 0.95, 0] }
: { x: 0, y: 0, opacity: claimed ? 0 : 1 }
}
transition={
claimed && !still
? {
duration: cfg.travel,
times: [0, 0.26, 1],
ease: [0.4, 0, 0.22, 1],
}
: { duration: still ? 0.2 : 0.18, ease: "easeOut" }
}
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1,
borderRadius: 11,
border: `1px dashed color-mix(in srgb, ${accent} 40%, transparent)`,
background: `color-mix(in srgb, ${accent} 12%, transparent)`,
pointerEvents: "none",
}}
>
<span
style={{
fontSize: 17,
fontWeight: 700,
letterSpacing: "-0.02em",
color: accent,
}}
>
{amount}
</span>
<span style={{ fontSize: 9, fontWeight: 600, color: accent, opacity: 0.75 }}>
{amountNote}
</span>
</motion.span>
</span>
<motion.span
initial={false}
animate={{ opacity: claimed ? 0.45 : 1 }}
transition={{ duration: 0.28, ease: "easeOut" }}
style={{ minWidth: 0 }}
>
<span style={{ display: "block", fontSize: 13, fontWeight: 640 }}>
{title}
</span>
<span
style={{
display: "block",
marginTop: 3,
fontSize: 11,
lineHeight: 1.4,
color: tone(50),
}}
>
{detail}
</span>
</motion.span>
</div>
<button
type="button"
onClick={() => setClaimed(true)}
disabled={claimed}
style={{
position: "relative",
overflow: "hidden",
width: "100%",
padding: "10px 14px",
borderRadius: 11,
border: `1px solid ${tone(13)}`,
background: tone(7),
color: "inherit",
fontFamily: "inherit",
fontSize: 12.5,
fontWeight: 650,
cursor: claimed ? "default" : "pointer",
}}
>
{/* The accent is a literal colour and the resting surface is a
color-mix() neutral, so the two are crossfaded rather than
interpolated — no engine can tween between them. Both sides
of the swap wait for arrival: the action cannot report a
result the voucher has not delivered yet, and the flight
itself is the acknowledgement of the press. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: landed ? 0 : 1 }}
transition={{ duration: 0.26, ease: "easeOut" }}
style={{ position: "absolute", inset: 0, background: accent }}
/>
<span style={{ position: "relative", display: "grid" }}>
<motion.span
initial={false}
animate={{ opacity: landed ? 0 : 1 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ gridArea: "1 / 1", color: "#FFFFFF" }}
>
{actionLabel}
</motion.span>
<motion.span
initial={false}
animate={{ opacity: landed ? 1 : 0 }}
transition={{ duration: 0.24, ease: "easeOut" }}
style={{ gridArea: "1 / 1", color: tone(62) }}
>
{claimedLabel}
</motion.span>
</span>
</button>
</div>
);
}About this pattern
Pressing claim does not conjure a reward — it relocates the one already on screen. The voucher leaves the offer on a shallow arc, and the wallet count ticks at the moment it arrives rather than on the press, so the number changes because something landed in it. Three details keep it honest. The voucher only translates and fades: it carries an amount, and an amount that shrinks on its way across the screen stops being money and starts being a particle. The slot it leaves keeps its size, so nothing in the card jumps as the reward goes. And the action crossfades between a literal accent fill and a theme-derived resting surface using two stacked layers, because those two colours cannot be interpolated by any engine. The wallet acknowledges the arrival with one tint that comes up and goes again — not a bounce, and not a flash.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Achievements
A redeemable offer moving into the rewards wallet with the balance updating on arrival.