All patterns

Points Earn

A credit chip lifts off the activity that earned it and the balance absorbs it on arrival.

achievementfriendlyenergeticautomatic · finite · starter · ~1.3s
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.

227 lines · react + motion only
import { useEffect, useRef } from "react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
} from "motion/react";

/**
 * Vibary · Points Earn Tally
 *
 * Points leaving the thing that earned them and arriving in the
 * balance. The credit chip lifts off the activity row, drifts toward
 * the total, and fades as it gets there — and the total only starts
 * moving once the chip is most of the way, so the balance appears to
 * change because the points arrived rather than on its own schedule.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color; the credit colour
 * is semantic and stays literal.
 * Works with zero props; tune via `variant`, `from`, `amount`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PointsEarnTallyProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Balance before the credit. */
  from?: number;
  /** Points earned. */
  amount?: number;
  /** Label above the balance. */
  balanceLabel?: string;
  /** What earned the points. */
  activity?: string;
  /** Timestamp under the activity. */
  activityNote?: string;
  /** Credit color. Semantic, so it stays literal. */
  accent?: string;
  /** Fires once the balance has finished counting. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** Beat before the chip lifts off. */
  delay: number;
  /** How long the chip takes to reach the balance. */
  travel: number;
  /** How long the balance takes to absorb the credit. */
  count: number;
  /** How far the chip drifts up, in px. */
  rise: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Short hop, quick tally. For points credited several times a session.
  subtle: { delay: 0.1, travel: 0.62, count: 0.4, rise: 34 },
  // The all-purpose setting.
  default: { delay: 0.18, travel: 0.85, count: 0.6, rise: 48 },
  // A longer drift, for a summary screen where the credit is the news.
  playful: { delay: 0.24, travel: 1.1, count: 0.8, rise: 58 },
};

const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function PointsEarnTally({
  variant = "default",
  from = 1240,
  amount = 25,
  balanceLabel = "Rewards balance",
  activity = "Weekly review submitted",
  activityNote = "Just now",
  accent = "#2E9E6B",
  onComplete,
}: PointsEarnTallyProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const still = !!reduceMotion;
  const total = from + amount;

  const tally = useMotionValue(still ? total : from);
  const digits = useTransform(tally, (v) =>
    Math.round(v).toLocaleString("en-US")
  );

  const onCompleteRef = useRef(onComplete);
  useEffect(() => {
    onCompleteRef.current = onComplete;
  }, [onComplete]);

  useEffect(() => {
    // Reduced motion: the balance is simply the new balance, and the
    // credit chip stays put as the record of where it came from.
    if (still) {
      tally.set(total);
      return;
    }
    tally.set(from);
    const controls = animate(tally, total, {
      duration: cfg.count,
      // Timed against the chip's flight, not against the mount: the
      // number moves because something landed in it.
      delay: cfg.delay + cfg.travel * 0.55,
      ease: [0.22, 1, 0.36, 1],
      onComplete: () => onCompleteRef.current?.(),
    });
    return () => controls.stop();
  }, [still, tally, from, total, cfg.count, cfg.delay, cfg.travel]);

  return (
    <div style={{ width: 280, display: "flex", flexDirection: "column", gap: 16 }}>
      <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
        <span style={{ fontSize: 11.5, color: tone(52), fontWeight: 550 }}>
          {balanceLabel}
        </span>
        <span
          style={{
            display: "flex",
            alignItems: "baseline",
            gap: 5,
            fontSize: 26,
            fontWeight: 660,
            letterSpacing: "-0.02em",
            fontVariantNumeric: "tabular-nums",
            lineHeight: 1.1,
          }}
        >
          {/* Driven by a motion value: the figure changes, its box never
              does. Counting digits that scale stop reading as a total. */}
          <motion.span>{digits}</motion.span>
          <span style={{ fontSize: 12.5, fontWeight: 550, color: tone(50) }}>
            pts
          </span>
        </span>
      </div>

      <div
        style={{
          position: "relative",
          display: "flex",
          alignItems: "center",
          gap: 10,
          padding: "11px 12px",
          borderRadius: 12,
          background: tone(6),
          border: `1px solid ${tone(11)}`,
        }}
      >
        <span
          aria-hidden
          style={{
            display: "grid",
            placeItems: "center",
            width: 28,
            height: 28,
            flex: "none",
            borderRadius: 9,
            background: tone(9),
            color: tone(62),
          }}
        >
          <svg
            width="15"
            height="15"
            viewBox="0 0 18 18"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <path d="M4 2.6h7.2L14 5.4v10H4z" />
            <path d="M6.4 8.4h5.2M6.4 11.4h3.6" />
          </svg>
        </span>

        <span style={{ display: "flex", flexDirection: "column", gap: 1 }}>
          <span style={{ fontSize: 12.5, fontWeight: 580 }}>{activity}</span>
          <span style={{ fontSize: 11, color: tone(46) }}>{activityNote}</span>
        </span>

        {/* The chip only ever translates and fades. It is text, so it
            never scales on the way up. */}
        <motion.span
          initial={
            still ? { opacity: 0 } : { opacity: 0, y: 6, x: 0 }
          }
          animate={
            still
              ? { opacity: 1 }
              : {
                  opacity: [0, 1, 1, 0],
                  y: [6, -4, -cfg.rise * 0.72, -cfg.rise],
                  x: [0, 0, 5, 9],
                }
          }
          transition={
            still
              ? { duration: 0.2, ease: "easeOut" }
              : {
                  delay: cfg.delay,
                  duration: cfg.travel,
                  times: [0, 0.16, 0.7, 1],
                  ease: [0.33, 0, 0.2, 1],
                }
          }
          style={{
            marginLeft: "auto",
            flex: "none",
            padding: "3px 8px",
            borderRadius: 999,
            fontSize: 11.5,
            fontWeight: 650,
            letterSpacing: "-0.01em",
            fontVariantNumeric: "tabular-nums",
            color: accent,
            background: `color-mix(in srgb, ${accent} 14%, transparent)`,
          }}
        >
          {`+${amount}`}
        </motion.span>
      </div>
    </div>
  );
}

About this pattern

Points have to come from somewhere, and this motion shows where. The credit chip lifts off the activity row, drifts toward the balance and fades out as it arrives, and the running total only begins counting once the chip is most of the way there. That single scheduling decision is what makes the balance look caused rather than coincidental — start the count on mount instead and the two events read as unrelated. Both halves are text, so both are held to the same rule: the chip translates and fades, the total is driven by a motion value inside a tabular-figure box, and neither ever scales or springs.

Loyalty points creditedReferral bonusRewards balance updateCashback earned

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

    Stars credited from an order and absorbed into the balance at the top.

Related patterns