Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/usage-breakdown.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook3 stories available:
Code
"use client";
import { forwardRef, type ReactNode, useMemo } from "react";
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
import { cn } from "../../lib/utils";
import { Badge } from "../badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../card";
export type UsageBreakdownTone = "danger" | "default" | "success" | "warning";
export type UsageBreakdownItem = {
description?: string;
icon?: ReactNode;
id: string;
label: string;
meta?: string;
tone?: UsageBreakdownTone;
trend?: {
direction: "down" | "up";
label: string;
};
value: number;
valueLabel?: string;
};
export type UsageBreakdownProps = React.ComponentPropsWithoutRef<
typeof Card
> & {
description?: string;
emptyMessage?: string;
items: UsageBreakdownItem[];
maxItems?: number;
title?: string;
};
type UsageBreakdownRowProps = {
item: UsageBreakdownItem;
maxValue: number;
rank: number;
totalValue: number;
};
const toneClasses: Record<UsageBreakdownTone, string> = {
danger:
"bg-destructive/10 text-destructive border-destructive/20 dark:text-destructive",
default: "bg-muted text-muted-foreground border-border",
success:
"bg-emerald-500/10 text-emerald-700 border-emerald-500/20 dark:text-emerald-300",
warning:
"bg-amber-500/10 text-amber-700 border-amber-500/20 dark:text-amber-300",
};
function formatPercent(value: number): string {
if (!Number.isFinite(value)) return "0%";
return `${Math.round(value)}%`;
}
const LARGE_VALUE_FORMATTER = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
});
const SMALL_VALUE_FORMATTER = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 1,
});
function formatValue(value: number): string {
return value >= 100
? LARGE_VALUE_FORMATTER.format(value)
: SMALL_VALUE_FORMATTER.format(value);
}
function getRelativeWidth(value: number, maxValue: number): number {
if (maxValue <= 0) return 0;
return Math.max((value / maxValue) * 100, 4);
}
function getShare(value: number, totalValue: number): number {
if (totalValue <= 0) return 0;
return (value / totalValue) * 100;
}
function UsageBreakdownTrend({ item }: { item: UsageBreakdownItem }) {
if (!item.trend) return null;
const trendTone = item.tone ?? "default";
const TrendIcon =
item.trend.direction === "down" ? ArrowDownRight : ArrowUpRight;
return (
<Badge className={cn("gap-1 border", toneClasses[trendTone])}>
<TrendIcon className="size-3.5" />
{item.trend.label}
</Badge>
);
}
function UsageBreakdownMeter({
maxValue,
value,
}: {
maxValue: number;
value: number;
}) {
return (
<div className="space-y-2">
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
aria-hidden="true"
className="h-full rounded-full bg-primary transition-[width]"
style={{ width: `${getRelativeWidth(value, maxValue)}%` }}
/>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Relative usage</span>
<span>{formatPercent(getShare(value, maxValue))}</span>
</div>
</div>
);
}
function UsageBreakdownRow({
item,
maxValue,
rank,
totalValue,
}: UsageBreakdownRowProps) {
return (
<li className="rounded-lg border bg-background/70 p-4">
<div className="flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md border bg-muted text-sm font-semibold text-muted-foreground">
{item.icon ?? rank}
</div>
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium text-foreground">
{item.label}
</span>
{item.meta ? (
<Badge className="border-border" variant="outline">
{item.meta}
</Badge>
) : null}
<UsageBreakdownTrend item={item} />
</div>
{item.description ? (
<p className="text-sm text-muted-foreground">
{item.description}
</p>
) : null}
</div>
<div className="text-left sm:text-right">
<div className="font-semibold text-foreground">
{item.valueLabel ?? formatValue(item.value)}
</div>
<div className="text-sm text-muted-foreground">
{formatPercent(getShare(item.value, totalValue))} of total
</div>
</div>
</div>
<UsageBreakdownMeter maxValue={maxValue} value={item.value} />
</div>
</div>
</li>
);
}
function getSortedItems(items: UsageBreakdownItem[], maxItems?: number) {
const rankedItems = [...items].sort(
(left, right) => right.value - left.value,
);
return typeof maxItems === "number"
? rankedItems.slice(0, maxItems)
: rankedItems;
}
const UsageBreakdown = forwardRef<HTMLDivElement, UsageBreakdownProps>(
(
{
className,
description,
emptyMessage = "No usage data available.",
items,
maxItems,
title = "Usage breakdown",
...props
},
ref,
) => {
const sortedItems = useMemo(
() => getSortedItems(items, maxItems),
[items, maxItems],
);
const totalValue = useMemo(
() => sortedItems.reduce((sum, item) => sum + item.value, 0),
[sortedItems],
);
const maxValue = sortedItems[0]?.value ?? 0;
return (
<Card className={cn("w-full", className)} ref={ref} {...props}>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description ? (
<CardDescription>{description}</CardDescription>
) : null}
</CardHeader>
<CardContent>
{sortedItems.length === 0 ? (
<div className="rounded-lg border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
) : (
<ol className="space-y-3">
{sortedItems.map((item, index) => (
<UsageBreakdownRow
item={item}
key={item.id}
maxValue={maxValue}
rank={index + 1}
totalValue={totalValue}
/>
))}
</ol>
)}
</CardContent>
</Card>
);
},
);
UsageBreakdown.displayName = "UsageBreakdown";
export { UsageBreakdown };
typescript