Combo Multiplier
The multiplier climbs while answers keep landing, and the charge bar drains toward the break.
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, 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Activity summary
Practice games showing a rising multiplier with a visible decay window.
Related patterns
- Streak AdvanceThe connector reaches today's cell, the cell fills, and the streak count rolls over.
- Habit Calendar FillToday's square fills in the month grid and the run it belongs to draws itself underneath.
- Badge UnlockAn earned badge lands and one band of light crosses its face — a single pass, then still.