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.
npx shadcn@latest add @paragon/billing-usageAlso installs: button
"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>
);
}