Sections

Billing Usage

A billing panel with a current-plan card, usage meters that fill on view with threshold ticks, and an invoice table with hover-revealed downloads.

Install

npx shadcn@latest add @paragon/billing-usage

Also installs: button

billing-usage.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { ArrowUpRight, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";

/* ------------------------------------------------------------------ */

interface Meter {
  label: string;
  used: string;
  limit: string;
  fraction: number;
}

const METERS: Meter[] = [
  { label: "API requests", used: "8.4M", limit: "10M", fraction: 0.84 },
  { label: "Storage", used: "212 GB", limit: "500 GB", fraction: 0.424 },
  { label: "Seats", used: "18", limit: "25", fraction: 0.72 },
];

type InvoiceStatus = "paid" | "open" | "failed";

interface Invoice {
  id: string;
  date: string;
  amount: string;
  status: InvoiceStatus;
}

const INVOICES: Invoice[] = [
  { id: "INV-0148", date: "Jul 1, 2026", amount: "$499.00", status: "open" },
  { id: "INV-0141", date: "Jun 1, 2026", amount: "$499.00", status: "paid" },
  { id: "INV-0134", date: "May 1, 2026", amount: "$499.00", status: "paid" },
  { id: "INV-0127", date: "Apr 1, 2026", amount: "$449.00", status: "failed" },
  { id: "INV-0120", date: "Mar 1, 2026", amount: "$449.00", status: "paid" },
];

const STATUS_STYLES: Record<InvoiceStatus, { label: string; className: string }> =
  {
    paid: {
      label: "Paid",
      className:
        "bg-emerald-600/10 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-400",
    },
    open: {
      label: "Open",
      className:
        "bg-sky-600/10 text-sky-700 dark:bg-sky-400/10 dark:text-sky-400",
    },
    failed: {
      label: "Failed",
      className:
        "bg-red-600/10 text-red-700 dark:bg-red-400/10 dark:text-red-400",
    },
  };

const WARN_AT = 0.8;

/* ------------------------------------------------------------------ */

function UsageMeter({
  meter,
  fill,
}: {
  meter: Meter;
  fill: boolean;
}) {
  const over = meter.fraction >= WARN_AT;
  return (
    <div>
      <div className="flex items-baseline justify-between gap-4 text-sm">
        <span className="font-medium">{meter.label}</span>
        <span className="text-muted-foreground tabular-nums">
          <span className={cn("font-medium", over ? "text-amber-600 dark:text-amber-400" : "text-foreground")}>
            {meter.used}
          </span>{" "}
          / {meter.limit}
        </span>
      </div>
      <div
        role="meter"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={Math.round(meter.fraction * 100)}
        aria-label={`${meter.label}: ${meter.used} of ${meter.limit} used`}
        className="relative mt-2 h-1.5 overflow-hidden rounded-full bg-muted"
      >
        <div
          className={cn(
            "h-full w-full origin-left rounded-full",
            "transition-transform duration-300 [transition-timing-function:var(--ease-out)]",
            over
              ? "bg-amber-500 dark:bg-amber-400"
              : "bg-primary",
          )}
          style={{ transform: `scaleX(${fill ? meter.fraction : 0})` }}
        />
        {/* Soft-limit threshold tick */}
        <span
          aria-hidden
          className="absolute inset-y-0 w-px bg-foreground/25"
          style={{ left: `${WARN_AT * 100}%` }}
        />
      </div>
    </div>
  );
}

export interface BillingUsageProps extends React.ComponentProps<"section"> {
  /** Plan name on the current-plan card. */
  planName?: string;
}

/**
 * A billing panel: current-plan card with upgrade CTA, per-resource usage
 * meters that fill when scrolled into view (transform-only, with a soft-limit
 * threshold tick), and an invoice table with status badges and a
 * hover-revealed download affordance (always visible on touch and focus).
 */
export function BillingUsage({
  planName = "Scale",
  className,
  ...props
}: BillingUsageProps) {
  const reducedMotion = useReducedMotion();
  const metersRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(metersRef, { once: true, amount: 0.4 });
  const fill = inView || !!reducedMotion;

  return (
    <section
      aria-label="Billing and usage"
      className={cn("w-full max-w-2xl", className)}
      {...props}
    >
      {/* Current plan */}
      <div className="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-card p-5 text-card-foreground shadow-border sm:p-6">
        <div>
          <div className="flex items-center gap-2">
            <h2 className="text-sm font-semibold">{planName} plan</h2>
            <span className="rounded-full bg-emerald-600/10 px-1.5 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-400">
              Active
            </span>
          </div>
          <p className="mt-1 text-sm text-muted-foreground">
            <span className="font-medium text-foreground tabular-nums">
              $499
            </span>
            /month · renews Aug 1, 2026
          </p>
        </div>
        <div className="flex items-center gap-2">
          <Button variant="ghost" size="sm">
            Manage plan
          </Button>
          <Button size="sm">
            Upgrade to Enterprise
            <ArrowUpRight aria-hidden />
          </Button>
        </div>
      </div>

      {/* Usage meters */}
      <div
        ref={metersRef}
        className="mt-4 rounded-xl bg-card p-5 text-card-foreground shadow-border sm:p-6"
      >
        <div className="flex items-baseline justify-between gap-4">
          <h2 className="text-sm font-semibold">Usage this period</h2>
          <span className="text-xs text-muted-foreground tabular-nums">
            Jul 1 – Jul 31
          </span>
        </div>
        <div className="mt-5 space-y-5">
          {METERS.map((meter) => (
            <UsageMeter key={meter.label} meter={meter} fill={fill} />
          ))}
        </div>
        <p className="mt-4 text-[13px] text-pretty text-muted-foreground">
          API requests are at{" "}
          <span className="font-medium text-amber-600 tabular-nums dark:text-amber-400">
            84%
          </span>{" "}
          of the plan limit. Overage is billed at $0.85 per 100k requests.
        </p>
      </div>

      {/* Invoices */}
      <div className="mt-4 rounded-xl bg-card text-card-foreground shadow-border">
        <h2 className="px-5 pt-5 text-sm font-semibold sm:px-6">Invoices</h2>
        <div className="mt-2 overflow-x-auto pb-2">
          <table className="w-full min-w-[26rem] text-sm">
            <thead>
              <tr className="text-left text-xs text-muted-foreground">
                <th scope="col" className="py-2 pl-5 font-medium sm:pl-6">
                  Invoice
                </th>
                <th scope="col" className="px-3 py-2 font-medium">
                  Date
                </th>
                <th scope="col" className="px-3 py-2 font-medium">
                  Status
                </th>
                <th scope="col" className="px-3 py-2 text-right font-medium">
                  Amount
                </th>
                <th scope="col" className="py-2 pr-5 sm:pr-6">
                  <span className="sr-only">Download</span>
                </th>
              </tr>
            </thead>
            <tbody>
              {INVOICES.map((invoice) => {
                const status = STATUS_STYLES[invoice.status];
                return (
                  <tr
                    key={invoice.id}
                    className="group border-t border-border/60 transition-[background-color] duration-150 ease-out hover:bg-muted/40"
                  >
                    <td className="py-2.5 pl-5 font-mono text-xs sm:pl-6">
                      {invoice.id}
                    </td>
                    <td className="px-3 py-2.5 whitespace-nowrap text-muted-foreground tabular-nums">
                      {invoice.date}
                    </td>
                    <td className="px-3 py-2.5">
                      <span
                        className={cn(
                          "inline-flex rounded-full px-1.5 py-0.5 text-xs font-medium",
                          status.className,
                        )}
                      >
                        {status.label}
                      </span>
                    </td>
                    <td className="px-3 py-2.5 text-right font-medium tabular-nums">
                      {invoice.amount}
                    </td>
                    <td className="py-2.5 pr-4 text-right sm:pr-5">
                      <button
                        type="button"
                        aria-label={`Download ${invoice.id}`}
                        className={cn(
                          "pressable relative inline-flex size-7 items-center justify-center rounded-md text-muted-foreground",
                          "transition-[opacity,color] duration-150 ease-out hover:text-foreground",
                          "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
                          // Hidden until row hover on pointer devices; always
                          // visible on touch, and surfaced by keyboard focus.
                          "[@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 [@media(hover:hover)]:focus-visible:opacity-100",
                        )}
                      >
                        <Download aria-hidden className="size-3.5" />
                      </button>
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
      </div>
    </section>
  );
}