All patterns

Combo Multiplier

The multiplier climbs while answers keep landing, and the charge bar drains toward the break.

achievementenergeticfuturisticautomatic · finite · advanced · ~2.5s
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.

279 lines · react + motion only
import { useEffect, useMemo, useRef, useState } from "react";
import {
  animate,
  AnimatePresence,
  motion,
  useMotionValue,
  useReducedMotion,
} from "motion/react";

/**
 * Vibary · Combo Multiplier Rise
 *
 * A multiplier that climbs while answers keep landing and drops back
 * when the run stops. The charge bar between hits is the honest part:
 * it drains in real time, so the multiplier's collapse is something the
 * viewer watched approaching rather than something that happened to
 * them.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The track and pips are mixed from the inherited text color; the live
 * colour is semantic and stays literal.
 * Works with zero props; tune via `variant`, `peak`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ComboMultiplierRiseProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Highest multiplier the run reaches. */
  peak?: number;
  /** Row label. */
  label?: string;
  /** Live colour. Semantic, so it stays literal. */
  accent?: string;
  /** Fires when the run breaks. */
  onBreak?: () => void;
};

type VariantConfig = {
  /** Time between consecutive hits, in ms. */
  beat: number;
  /** How long the last hit is held before the run breaks, in ms. */
  hold: number;
  /** Vertical travel of a rolling digit, as a fraction of its slot. */
  rollTravel: number;
};

const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Tight and quick, for a multiplier in a toolbar.
  subtle: { beat: 340, hold: 700, rollTravel: 0.5 },
  // The all-purpose setting.
  default: { beat: 480, hold: 1000, rollTravel: 0.8 },
  // A longer run and a longer hang before the break.
  playful: { beat: 620, hold: 1350, rollTravel: 1 },
};

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

const SLOT = 38;

export default function ComboMultiplierRise({
  variant = "default",
  peak = 4,
  label = "Combo",
  accent = "#4C6FFF",
  onBreak,
}: ComboMultiplierRiseProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const still = !!reduceMotion;

  const top = Math.max(2, peak);
  // The whole run as data: a rise per beat, then a break after the hold.
  // Derived, so it is memoised rather than parked in a ref — the render
  // reads it to draw the current multiplier, and a ref may not be read
  // during render.
  const script = useMemo<{ at: number; mult: number; broken?: boolean }[]>(
    () => [
      ...Array.from({ length: top - 1 }, (_, index) => ({
        at: cfg.beat * (index + 1),
        mult: index + 2,
      })),
      { at: cfg.beat * (top - 1) + cfg.hold, mult: 1, broken: true },
    ],
    [cfg.beat, cfg.hold, top]
  );

  // A new peak — or a new variant — is a new run, so the step has to go
  // back to the start. That reset is a render-time fact: the run key
  // lives in state and is compared during render, which leaves the effect
  // below owning only the timers and the charge.
  const runKey = `${still}:${cfg.beat}:${cfg.hold}:${top}`;
  const fresh = { key: runKey, step: still ? script.length - 1 : -1 };
  const [run, setRun] = useState(fresh);
  if (run.key !== runKey) setRun(fresh);
  const step = run.key === runKey ? run.step : fresh.step;

  const charge = useMotionValue(still ? 0 : 1);

  const onBreakRef = useRef(onBreak);
  useEffect(() => {
    onBreakRef.current = onBreak;
  }, [onBreak]);

  useEffect(() => {
    // Reduced motion: the run's outcome is shown directly. A decaying
    // charge has nothing to say once nobody is watching it decay.
    if (still) {
      charge.set(0);
      return;
    }

    charge.set(1);
    let controls = animate(charge, 0, {
      duration: cfg.beat / 1000,
      ease: "linear",
    });

    const timers = script.map((entry, index) =>
      setTimeout(() => {
        controls.stop();
        setRun({ key: runKey, step: index });
        if (entry.broken) {
          charge.set(0);
          onBreakRef.current?.();
          return;
        }
        charge.set(1);
        const window = (script[index + 1].at - entry.at) / 1000;
        controls = animate(charge, 0, { duration: window, ease: "linear" });
      }, entry.at)
    );

    return () => {
      timers.forEach(clearTimeout);
      controls.stop();
    };
  }, [still, charge, script, cfg.beat, runKey]);

  const current = step < 0 ? 1 : script[step].mult;
  const broken = step >= 0 && !!script[step].broken;
  const live = !broken && current > 1;
  const travel = SLOT * cfg.rollTravel;
  // Rising digits come up from below; the break drops in from above, so
  // the direction of the roll carries the direction of the news.
  const dir = broken ? -1 : 1;

  return (
    <div style={{ width: 264, display: "flex", flexDirection: "column", gap: 12 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <span
          style={{
            fontSize: 10.5,
            fontWeight: 640,
            letterSpacing: "0.11em",
            textTransform: "uppercase",
            color: tone(46),
          }}
        >
          {label}
        </span>
        <span style={{ marginLeft: "auto", display: "flex", gap: 5 }}>
          {Array.from({ length: top - 1 }, (_, index) => (
            <motion.span
              key={index}
              initial={false}
              animate={{ opacity: !broken && current > index + 1 ? 1 : 0.22 }}
              transition={{ duration: still ? 0 : 0.2, ease: "easeOut" }}
              style={{
                width: 16,
                height: 3,
                borderRadius: 999,
                background: broken ? tone(40) : accent,
              }}
            />
          ))}
        </span>
      </div>

      <div style={{ display: "flex", alignItems: "flex-end", gap: 3 }}>
        <motion.span
          initial={false}
          animate={{ opacity: live ? 1 : 0.42 }}
          transition={{ duration: still ? 0 : 0.24, ease: "easeOut" }}
          style={{
            fontSize: 22,
            fontWeight: 600,
            color: live ? accent : tone(58),
            lineHeight: 1.6,
          }}
        >
          {"×"}
        </motion.span>

        {/* The figure rolls inside a fixed slot: translate and crossfade,
            constant size. A multiplier that pops on each hit is the
            single cheapest gesture in this whole category. */}
        <span
          style={{
            position: "relative",
            display: "inline-block",
            width: "1ch",
            height: SLOT,
            overflow: "hidden",
            fontSize: 34,
            fontWeight: 680,
            letterSpacing: "-0.02em",
            fontVariantNumeric: "tabular-nums",
          }}
        >
          <AnimatePresence initial={false}>
            <motion.span
              key={current}
              initial={{ y: still ? 0 : dir * travel, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: still ? 0 : -dir * travel, opacity: 0 }}
              transition={{ duration: still ? 0 : 0.3, ease: [0.22, 1, 0.36, 1] }}
              style={{
                position: "absolute",
                inset: 0,
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                color: live ? accent : tone(62),
              }}
            >
              {current}
            </motion.span>
          </AnimatePresence>
        </span>
      </div>

      <div
        style={{
          height: 4,
          borderRadius: 999,
          background: tone(10),
          overflow: "hidden",
        }}
      >
        {/* Linear, deliberately. The charge is a countdown, and easing a
            countdown misreports how much time is left. */}
        <motion.div
          style={{
            height: "100%",
            borderRadius: 999,
            background: broken ? tone(30) : accent,
            transformOrigin: "left center",
            scaleX: charge,
          }}
        />
      </div>

      <div style={{ minHeight: 16, fontSize: 11.5 }}>
        <AnimatePresence initial={false} mode="wait">
          <motion.span
            key={broken ? "broken" : `run-${current}`}
            initial={{ opacity: 0, y: still ? 0 : 4 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: still ? 0 : -4 }}
            transition={{ duration: still ? 0 : 0.2, ease: "easeOut" }}
            style={{
              display: "inline-block",
              fontWeight: 550,
              color: broken ? tone(46) : live ? accent : tone(46),
            }}
          >
            {broken
              ? "Run ended · multiplier reset"
              : current > 1
                ? `${current - 1} correct in a row`
                : "Answer to start a run"}
          </motion.span>
        </AnimatePresence>
      </div>
    </div>
  );
}

About this pattern

A run of consecutive successes, with the cost of stopping made visible. Each hit rolls the multiplier up a step and refills the charge bar; between hits the bar drains in real time on a strictly linear curve, because a countdown that eases misreports how much time is left. When the run ends the multiplier drops back with the digit arriving from above rather than below, so the direction of the roll carries the direction of the news. The figure lives in a fixed slot and only ever translates and crossfades — a multiplier that pops on every hit is the single cheapest gesture available in this category, and it is the one thing this pattern is careful not to do.

Quiz answer streakPractice session comboConsecutive correct answersScoring multiplier

Where it shows up

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

  • 10:15
    Today
    412 kcalMove8,240Steps9 hrsStand
    HomeSearchActivityProfile
    Activity summary

    Practice games showing a rising multiplier with a visible decay window.

Related patterns