Credit Deduct Tick
A usage balance ticks down to its new figure while the amount taken rises beside it and leaves.
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 · Credit Deduct Tick
*
* A usage balance paying for what just ran. The figure ticks down
* through a few intermediate values so the movement is legible as a
* subtraction, the amount taken rises beside it and leaves, and the
* remaining-quota bar shortens by exactly as much. Digits move by
* translation only — a balance that scales while it changes is a balance
* nobody trusts.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `balance`, `spent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CreditDeductTickProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** What the balance settles at. */
balance?: number;
/** How much this action took. */
spent?: number;
/** The full monthly allowance, for the remaining bar. */
allowance?: number;
/** What the balance is called. */
unit?: string;
/** Line under the figure. */
note?: string;
/** Beat before the deduction lands, in ms. */
delayMs?: number;
/** Accent for the bar and the amount taken. */
accent?: string;
/** Fires once the balance has settled. */
onSettled?: () => void;
};
type VariantConfig = {
/** Intermediate values the tick passes through. */
ticks: number;
/** Total length of the tick, in ms. */
tickMs: number;
/** Crossfade for one digit handing over to the next. */
fadeSeconds: number;
/** px the amount taken rises as it leaves. */
chipRise: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// A balance is checked, not watched, so the digits land rather than
// settle: damping ratios (ζ = damping / 2√stiffness) stay at or above
// 0.95. Variants change how many values the tick passes through and how
// far the chip travels, never how much anything wobbles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.00, two intermediate values — nearly a cut. For a balance that
// changes on every keystroke.
subtle: {
ticks: 2,
tickMs: 260,
fadeSeconds: 0.1,
chipRise: 10,
spring: { type: "spring", stiffness: 620, damping: 50 },
},
// ζ ≈ 0.98. The all-purpose setting.
default: {
ticks: 4,
tickMs: 460,
fadeSeconds: 0.13,
chipRise: 16,
spring: { type: "spring", stiffness: 480, damping: 43 },
},
// ζ ≈ 0.95, a longer count — for a header where the balance is the
// thing being watched.
playful: {
ticks: 6,
tickMs: 680,
fadeSeconds: 0.16,
chipRise: 22,
spring: { type: "spring", stiffness: 400, damping: 38 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one.
* The accent stays literal — it carries meaning. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const ACCENT = "#7C7CF0";
const DIGIT_HEIGHT = 30;
const format = (value: number) => value.toLocaleString("en-US");
export default function CreditDeductTick({
variant = "default",
balance = 1225,
spent = 15,
allowance = 2000,
unit = "credits left",
note = "Allowance resets on 1 September",
delayMs = 800,
accent = ACCENT,
onSettled,
}: CreditDeductTickProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const opening = balance + spent;
const [current, setCurrent] = useState(opening);
const [charged, setCharged] = useState(false);
const onSettledRef = useRef(onSettled);
useEffect(() => {
onSettledRef.current = onSettled;
}, [onSettled]);
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
const finish = () => {
setCurrent(balance);
onSettledRef.current?.();
};
timers.push(setTimeout(() => setCharged(true), delayMs));
if (reduceMotion) {
// The new balance is the information; the count was the delivery.
timers.push(setTimeout(finish, delayMs));
} else {
const steps = Math.max(1, Math.min(cfg.ticks, spent));
for (let step = 1; step <= steps; step++) {
const at = delayMs + (cfg.tickMs * step) / steps;
const figure = Math.round(opening - (spent * step) / steps);
timers.push(
setTimeout(step === steps ? finish : () => setCurrent(figure), at)
);
}
}
return () => {
for (const timer of timers) clearTimeout(timer);
};
}, [balance, spent, opening, delayMs, cfg, reduceMotion]);
const share = Math.max(0, Math.min(1, current / allowance));
return (
<div
style={{
width: 252,
padding: "13px 15px 14px",
borderRadius: 14,
background: tone(5),
border: `1px solid ${tone(10)}`,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 7,
fontSize: 10.5,
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
opacity: 0.5,
}}
>
<svg width="12" height="12" viewBox="0 0 14 14" fill="none" aria-hidden>
<circle cx="7" cy="7" r="5.4" stroke="currentColor" strokeWidth="1.3" />
<path
d="M9 5.2a2.6 2.6 0 1 0 0 3.6"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
Usage balance
</div>
<div
style={{
position: "relative",
display: "flex",
alignItems: "baseline",
gap: 7,
marginTop: 7,
}}
>
<Figure
value={format(current)}
reserve={format(opening)}
cfg={cfg}
still={Boolean(reduceMotion)}
/>
<span style={{ fontSize: 11.5, opacity: 0.5 }}>{unit}</span>
{/* The amount taken is a passing remark, not a state: it rises,
fades and is gone, leaving the balance as the only thing on
screen that still says what happened. */}
<AnimatePresence>
{charged && (
<motion.span
key="delta"
initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
animate={{
opacity: [0, 1, 1, 0],
y: reduceMotion ? 0 : [6, 0, -cfg.chipRise * 0.4, -cfg.chipRise],
}}
transition={{
duration: reduceMotion ? 0.9 : 1.25,
times: [0, 0.18, 0.6, 1],
ease: "easeOut",
}}
style={{
position: "absolute",
right: 0,
bottom: 4,
padding: "2px 7px",
borderRadius: 999,
fontSize: 11,
fontWeight: 650,
color: accent,
background: `color-mix(in srgb, ${accent} 14%, transparent)`,
fontVariantNumeric: "tabular-nums",
pointerEvents: "none",
}}
>
−{format(spent)}
</motion.span>
)}
</AnimatePresence>
</div>
{/* The bar shortens by exactly what the figure lost, so the two
readings can never tell different stories. */}
<div
style={{
marginTop: 10,
height: 4,
borderRadius: 3,
background: tone(9),
overflow: "hidden",
}}
>
<motion.div
initial={false}
animate={{ scaleX: share }}
transition={{
duration: reduceMotion ? 0.2 : 0.5,
ease: "easeOut",
}}
style={{
height: "100%",
borderRadius: 3,
background: accent,
transformOrigin: "left center",
}}
/>
</div>
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.45 }}>{note}</div>
<span
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{current === balance ? `${format(spent)} used. ${format(balance)} ${unit}.` : ""}
</span>
</div>
);
}
/**
* The balance. Each digit column that changes rolls downward — incoming
* from above, outgoing through the floor — because the number is going
* down and the direction is the fastest read on the panel. A hidden copy
* of the opening figure reserves the width so nothing beside it moves.
*/
function Figure({
value,
reserve,
cfg,
still,
}: {
value: string;
reserve: string;
cfg: VariantConfig;
still: boolean;
}) {
const chars = value.split("");
const travel = still ? 0 : DIGIT_HEIGHT;
return (
<span
aria-hidden
style={{
position: "relative",
display: "inline-block",
fontSize: 24,
fontWeight: 650,
letterSpacing: -0.4,
lineHeight: `${DIGIT_HEIGHT}px`,
fontVariantNumeric: "tabular-nums",
}}
>
<span style={{ visibility: "hidden" }}>{reserve}</span>
<span
style={{
position: "absolute",
left: 0,
top: 0,
display: "inline-flex",
alignItems: "flex-start",
}}
>
{chars.map((char, index) => {
const column = chars.length - 1 - index;
if (!/[0-9]/.test(char)) {
return (
<span key={`sep-${column}`} style={{ display: "inline-block" }}>
{char}
</span>
);
}
return (
<span
key={`col-${column}`}
style={{
display: "inline-grid",
height: DIGIT_HEIGHT,
overflow: "hidden",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={char}
initial={{ y: -travel, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: travel, opacity: 0 }}
transition={{
y: cfg.spring,
opacity: { duration: cfg.fadeSeconds, ease: "easeOut" },
}}
style={{ gridArea: "1 / 1", display: "block" }}
>
{char}
</motion.span>
</AnimatePresence>
</span>
);
})}
</span>
</span>
);
}About this pattern
The receipt for something that just ran on metered usage. The balance passes through a few intermediate figures rather than cutting straight to the answer, because a subtraction you can watch is a subtraction you believe; changed digit columns roll downward, in the direction the balance moved, and the type holds one size the whole way. The amount taken is treated as a passing remark — it rises beside the figure, fades, and is gone — so the balance ends up as the only thing on screen still saying what happened. The remaining-quota bar shortens by exactly the same amount, so the two readings can never tell different stories.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
A usage balance updates after a run, with the charge itemised beside it.
Related patterns
- Quota Limit NudgeA usage meter nearing its cap gives one restrained nudge, then reveals the upgrade.
- Success Check RevealA success badge springs into place while the checkmark draws itself along its path.
- Session Expiry CountdownA session-timeout dialog counts down on a draining ring with a clear stay-signed-in action.