All patterns

Reward Claim

Claiming moves the voucher out of the offer and into the wallet, whose count ticks as it lands.

achievementfriendlyenergeticinteraction · 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.

373 lines · react + motion only
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.

Referral credit claimedLoyalty coupon collectedPromo code saved to an accountRedeeming an earned perk

Where it shows up

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

  • 10:15
    Achievements
    First orderUnlocked today
    Five-day streakUnlocked Tue
    Early riserUnlocked last week
    Full monthLocked
    HomeSearchActivityProfile
    Achievements

    A redeemable offer moving into the rewards wallet with the balance updating on arrival.

Related patterns