# Banner Upgrade Progress — React Banner Component

> An in-app usage banner whose meter springs to the current value, counts the number up and shifts from calm to amber to red as the plan limit nears.

- Category: [Banners](https://pixelpapa.com/components/banners.md)
- Tags: banner, upgrade, usage, meter, progress
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/banners/banner-upgrade-progress

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/banner-upgrade-progress.json"
```

The file is written to `components/pixelpapa/banner-upgrade-progress.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ArrowRight, Gauge, X } from "lucide-react";
import { animate, AnimatePresence, motion, useMotionValue, useTransform } from "motion/react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";

type BannerUpgradeProgressProps = {
  used?: number;
  limit?: number;
  unit?: string;
  plan?: string;
  resetsIn?: string;
  onDismiss?: () => void;
  className?: string;
};

export function BannerUpgradeProgress({
  used = 8420,
  limit = 10000,
  unit = "tracked events",
  plan = "Starter",
  resetsIn = "9 days",
  onDismiss,
  className,
}: BannerUpgradeProgressProps) {
  const [visible, setVisible] = useState(true);
  const ratio = Math.min(1, used / limit);
  const level = ratio >= 0.95 ? "critical" : ratio >= 0.8 ? "warning" : "ok";
  const count = useMotionValue(0);
  const shown = useTransform(count, (v) => Math.round(v).toLocaleString("en-US"));

  useEffect(() => {
    const controls = animate(count, used, { duration: 1.1, ease: [0.22, 1, 0.36, 1] });
    return () => controls.stop();
  }, [count, used]);

  const fill = { ok: "bg-primary", warning: "bg-amber-500", critical: "bg-red-500" }[level];

  return (
    <AnimatePresence initial={false}>
      {visible && (
        <motion.div
          role="region"
          aria-label="Plan usage"
          initial={{ height: 0, opacity: 0 }}
          animate={{ height: "auto", opacity: 1 }}
          exit={{ height: 0, opacity: 0, marginBottom: 0 }}
          transition={{ type: "spring", stiffness: 300, damping: 32 }}
          className={cn("overflow-hidden", className)}
        >
          <div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 sm:flex-row sm:items-center sm:gap-6 sm:p-5">
            <span
              className={cn(
                "flex size-11 shrink-0 items-center justify-center rounded-xl transition-colors duration-500",
                level === "ok" && "bg-primary/10 text-primary",
                level === "warning" && "bg-amber-500/15 text-amber-600 dark:text-amber-400",
                level === "critical" && "bg-red-500/15 text-red-600 dark:text-red-400",
              )}
            >
              <Gauge className="size-5" />
            </span>

            <div className="min-w-0 flex-1">
              <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
                <p className="text-sm text-card-foreground">
                  <motion.span className="font-semibold tabular-nums">{shown}</motion.span>
                  <span className="text-muted-foreground"> of {limit.toLocaleString("en-US")} {unit}</span>
                </p>
                <p className="text-xs text-muted-foreground">
                  {level === "critical" ? "Tracking pauses at the limit" : `${plan} plan · resets in ${resetsIn}`}
                </p>
              </div>
              <div
                role="progressbar"
                aria-valuemin={0}
                aria-valuemax={limit}
                aria-valuenow={used}
                aria-label={`${unit} used`}
                className="relative mt-2.5 h-2.5 rounded-full bg-muted"
              >
                <motion.div
                  initial={{ width: 0 }}
                  animate={{ width: `${ratio * 100}%` }}
                  transition={{ type: "spring", stiffness: 60, damping: 16 }}
                  className={cn("relative h-full overflow-hidden rounded-full transition-colors duration-500", fill)}
                >
                  <span
                    aria-hidden
                    className="absolute inset-0 bg-[linear-gradient(110deg,transparent_30%,rgb(255_255_255/0.45)_50%,transparent_70%)] bg-[length:200%_100%] motion-reduce:hidden"
                    style={{ animation: "pp-banner-upgrade-progress-shine 2.4s linear infinite" }}
                  />
                </motion.div>
                {/* Tick at the 80% warning threshold. */}
                <span aria-hidden className="absolute -top-1 left-[80%] h-[18px] w-px bg-foreground/30" />
              </div>
              <style>{`@keyframes pp-banner-upgrade-progress-shine { from { background-position: 200% 0; } to { background-position: -200% 0; } }`}</style>
            </div>

            <div className="flex shrink-0 items-center gap-2">
              <a href="#" className="group inline-flex h-10 items-center gap-2 rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground">
                Upgrade to Growth
                <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />
              </a>
              <button
                type="button"
                aria-label="Dismiss usage banner"
                onClick={() => {
                  setVisible(false);
                  onDismiss?.();
                }}
                className="flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
              >
                <X className="size-4" />
              </button>
            </div>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

const tiles = [
  ["Visitors", "24,180", "+8.2%"],
  ["Signups", "1,094", "+3.6%"],
  ["Conversion", "4.5%", "−0.2%"],
];

export default function BannerUpgradeProgressDemo() {
  const [used, setUsed] = useState(8420);

  return (
    <div className="h-[440px] w-full overflow-hidden bg-background">
      <header className="border-b border-border">
        <div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-6 text-sm">
          <span className="flex items-center gap-2 font-semibold text-foreground">
            <span className="size-5 rounded-full bg-primary" /> Beacon Analytics
          </span>
          <span className="text-muted-foreground">Workspace: Halden Coffee</span>
        </div>
      </header>
      <div className="mx-auto max-w-5xl px-6 pt-6">
        <BannerUpgradeProgress used={used} className="mb-6" />
        <div className="flex items-center justify-between">
          <h2 className="text-xl font-semibold tracking-tight text-foreground">This month</h2>
          <button
            type="button"
            onClick={() => setUsed((u) => (u >= 10000 ? 5200 : Math.min(10000, u + 780)))}
            className="rounded-full border border-border px-3.5 py-1.5 text-sm text-muted-foreground hover:text-foreground"
          >
            Simulate +780 events
          </button>
        </div>
        <div className="mt-4 grid gap-4 sm:grid-cols-3">
          {tiles.map(([label, value, delta]) => (
            <div key={label} className="rounded-2xl border border-border bg-card p-5">
              <p className="text-xs text-muted-foreground">{label}</p>
              <p className="mt-2 text-2xl font-semibold tracking-tight text-card-foreground tabular-nums">{value}</p>
              <p className="mt-1 text-xs font-medium text-primary">{delta}</p>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}
```

## AI prompt

```text
Add the "Banner Upgrade Progress" component from PixelPapa UI to this project.

What it is: An in-app usage banner whose meter springs to the current value, counts the number up and shifts from calm to amber to red as the plan limit nears.
Category: Banners. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/banner-upgrade-progress.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/banner-upgrade-progress.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/banner-upgrade-progress" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More banners

- [Banner Announcement Marquee](https://pixelpapa.com/components/banners/banner-announcement-marquee.md): A top strip that scrolls a seamless loop of announcements between faded edges, pauses on hover or focus and collapses when dismissed.
- [Banner Cookie Consent](https://pixelpapa.com/components/banners/banner-cookie-consent.md) (Pro): A corner consent card whose preferences panel unfolds with spring height, with real switches per category and a chip to reopen it after a choice.
- [Banner Countdown Sale](https://pixelpapa.com/components/banners/banner-countdown-sale.md) (Pro): A sale strip with a ticking countdown whose digits roll over, a sweeping sheen, and a promo code that confirms itself when copied.
- [Banner Newsletter Inline](https://pixelpapa.com/components/banners/banner-newsletter-inline.md): An in-article signup banner: on submit the paper plane flies off, the field collapses and a confirmation line draws itself in.
- [Banner Status Incident](https://pixelpapa.com/components/banners/banner-status-incident.md) (Pro): A status strip with a pulsing severity dot that expands on a spring into a timeline of incident updates, recoloured by severity.
