Quota Limit Nudge
A usage meter nearing its cap gives one restrained nudge, then reveals the upgrade.
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, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Quota Limit Nudge
*
* A usage meter that fills toward its cap, warms to amber, gives one
* short nudge — and only then offers the upgrade.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Neutrals are mixed from the inherited text color, so the card reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `used`, `limit`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type QuotaLimitNudgeProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** What has been consumed so far. */
used?: number;
/** The cap. */
limit?: number;
unit?: string;
/** Fraction of the cap at which the meter warms and nudges. */
warnAt?: number;
/** Copy for the offer that appears after the nudge. */
offerText?: string;
offerAction?: string;
};
type VariantConfig = {
/** How long the meter takes to reach its value. */
fillSeconds: number;
/** px of the single sideways nudge. */
nudge: number;
nudgeSeconds: number;
/** The offer row arriving. */
spring: { type: "spring"; stiffness: number; damping: number };
revealSeconds: number;
};
// One nudge, never a repeat: a meter that shakes is scolding the reader
// for using the product. Damping ratios (damping / 2√stiffness) stay at
// or above 0.8 so the offer row lands flat under it.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost imperceptible bump. For a dashboard people read all day.
subtle: {
fillSeconds: 0.6,
nudge: 2,
nudgeSeconds: 0.24,
spring: { type: "spring", stiffness: 530, damping: 46 },
revealSeconds: 0.2,
},
// A bump you notice once and then forget. The all-purpose setting.
default: {
fillSeconds: 0.9,
nudge: 5,
nudgeSeconds: 0.3,
spring: { type: "spring", stiffness: 400, damping: 36 },
revealSeconds: 0.26,
},
// The most this is allowed to be: still one bump, just a longer one.
playful: {
fillSeconds: 1.1,
nudge: 7,
nudgeSeconds: 0.36,
spring: { type: "spring", stiffness: 310, damping: 29 },
revealSeconds: 0.32,
},
};
const ACCENT = "#7C7CF0";
const WARN = "#E0A33E";
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function QuotaLimitNudge({
variant = "default",
used = 4850,
limit = 5000,
unit = "API calls",
warnAt = 0.9,
offerText = "Requests are throttled once you hit the cap.",
offerAction = "Upgrade plan",
}: QuotaLimitNudgeProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const ratio = Math.min(1, Math.max(0, limit > 0 ? used / limit : 0));
const nearCap = ratio >= warnAt;
// 0 filling · 1 the single nudge · 2 the offer is on the table.
const [phase, setPhase] = useState(0);
useEffect(() => {
if (!nearCap) return;
if (reduceMotion) {
// No nudge at all; the offer still arrives, just without the bump.
const timer = setTimeout(() => setPhase(2), 260);
return () => clearTimeout(timer);
}
const settled = cfg.fillSeconds * 1000;
const timers = [
setTimeout(() => setPhase(1), settled + 120),
setTimeout(() => setPhase(2), settled + 320),
];
return () => timers.forEach(clearTimeout);
}, [nearCap, reduceMotion, cfg.fillSeconds]);
const percent = Math.round(ratio * 100);
const numberStyle = { fontVariantNumeric: "tabular-nums" as const };
return (
<div
style={{
width: 300,
padding: 16,
borderRadius: 16,
background: tone(5),
border: `1px solid ${tone(12)}`,
}}
>
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
}}
>
<span style={{ fontSize: 13, fontWeight: 600 }}>{unit}</span>
<span style={{ fontSize: 11.5, opacity: 0.55, ...numberStyle }}>
{used.toLocaleString("en-US")} of {limit.toLocaleString("en-US")}
</span>
</div>
{/* The nudge moves the track only. The numbers beside it stay put:
text that hops is text the reader has to re-find. */}
<motion.div
animate={phase >= 1 && !reduceMotion ? { x: [0, -cfg.nudge, 0] } : { x: 0 }}
transition={{
duration: cfg.nudgeSeconds,
// Out fast, back slow: one damped bump, not an oscillation.
times: [0, 0.35, 1],
ease: [0.22, 1, 0.36, 1],
}}
style={{
marginTop: 10,
height: 7,
borderRadius: 999,
background: tone(12),
overflow: "hidden",
}}
role="meter"
aria-label={`${unit} used`}
aria-valuemin={0}
aria-valuemax={limit}
aria-valuenow={used}
aria-valuetext={`${percent}% of ${limit.toLocaleString("en-US")}`}
>
{/* scaleX rather than width: the fill costs nothing per frame and
cannot relayout the card while it grows. */}
<motion.div
initial={{ scaleX: 0, backgroundColor: ACCENT }}
animate={{ scaleX: ratio, backgroundColor: nearCap ? WARN : ACCENT }}
transition={{
scaleX: reduceMotion
? { duration: 0 }
: { duration: cfg.fillSeconds, ease: [0.22, 1, 0.36, 1] },
backgroundColor: {
duration: reduceMotion ? 0.2 : 0.35,
delay: reduceMotion ? 0 : cfg.fillSeconds * 0.55,
ease: "easeOut",
},
}}
style={{
height: "100%",
borderRadius: 999,
transformOrigin: "left center",
}}
/>
</motion.div>
{/* A genuine size change, so it is a short height tween — but the
content inside rides in on a spring, which is what makes the
offer feel offered rather than dumped. */}
<motion.div
initial={false}
animate={{
height: phase >= 2 ? "auto" : 0,
opacity: phase >= 2 ? 1 : 0,
}}
transition={{
height: {
duration: reduceMotion ? 0.18 : cfg.revealSeconds,
ease: [0.4, 0, 0.2, 1],
},
opacity: { duration: 0.2, ease: "easeOut" },
}}
style={{ overflow: "hidden" }}
>
<motion.div
initial={false}
animate={{ y: phase >= 2 || reduceMotion ? 0 : 6 }}
transition={reduceMotion ? { duration: 0 } : cfg.spring}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
marginTop: 12,
paddingTop: 12,
borderTop: `1px solid ${tone(10)}`,
}}
>
<span style={{ fontSize: 11.5, opacity: 0.55, lineHeight: 1.4 }}>
{offerText}
</span>
<button
type="button"
style={{
flexShrink: 0,
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: 0,
background: "none",
border: 0,
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
color: WARN,
}}
>
{offerAction}
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d="M2.5 6h7M6.6 3l3 3-3 3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</motion.div>
</motion.div>
</div>
);
}About this pattern
The tactful version of an upsell. The meter fills to its real value, warms from accent to amber as it crosses the warning threshold, and then the track alone gives a single sideways bump — out fast, back slow, so it reads as one damped nudge rather than an oscillation. Only after that does the upgrade line open underneath on a short height tween with its content riding a spring. The numbers never move, the bump never repeats, and reduced motion drops the bump entirely while keeping the warning and the offer.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Modal sheet
Storage meter warms as it fills and surfaces the upgrade only once it matters.
Related patterns
- Credit Deduct TickA usage balance ticks down to its new figure while the amount taken rises beside it and leaves.
- Processing Steps CheckThree stages clear across a horizontal rail, each hand-off filling the segment to the next marker.
- Delete Confirm MorphA delete control widens in place into a question with a way out, instead of opening a dialog.