All patterns

Quota Limit Nudge

A usage meter nearing its cap gives one restrained nudge, then reveals the upgrade.

feedbackcalmpremiumautomatic · finite · intermediate · ~1.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.

253 lines · react + motion only
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.

Plan usage nearing its capStorage almost fullSeat limit reachedRate limit warning

Where it shows up

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

  • Ridgeline
    Issues
    Backlog
    Active
    Cycles
    Views
    IssuesNew
    Colourway picker drops a frameRID-412 · PriyaIn progress
    Receipt totals misalign on narrowRID-408 · MarcusTodo
    Session expires without warningRID-401 · DanaIn review
    Export queue stalls past 500 rowsRID-397 · NilsTodo
    Search ranks archived firstRID-390 · PriyaDone
    Delete this project?Its 21 files and every share link stop working. This cannot be undone.
    CancelDelete
    Modal sheet

    Storage meter warms as it fills and surfaces the upgrade only once it matters.

Related patterns