"use client";
import * as React from "react";
export interface StorageSegment {
/** Stable identity, used to animate a segment from its previous width. */
key: string;
label: string;
/** Amount in the same unit as `capacity` (bytes by default). */
value: number;
/** Segment colour (any CSS colour). Falls back to a built-in palette. */
color?: string;
}
export interface StorageMeterProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
segments: StorageSegment[];
/** Total capacity, in the same unit as the segment values. */
capacity: number;
/** Accessible and visible name of the meter. */
label?: string;
/** Formats amounts. Defaults to decimal bytes via Intl (kB, MB, GB, TB). */
format?: (value: number) => string;
/** Locale for the default formatter and percentages (fixed, so server and client agree). */
locale?: string;
/** Fraction of capacity (0–1) at which the status turns to a warning. */
warningAt?: number;
/** Fraction of capacity (0–1) at which the status turns critical. */
criticalAt?: number;
showLegend?: boolean;
/** Bar thickness in px. */
thickness?: number;
/** Gap between segments in px. */
gap?: number;
/** Width transition duration in ms. */
duration?: number;
}
const PALETTE = ["#FF7448", "#B6F3D1", "#7DB7F0", "#E8C07A", "#A1A1AA", "#F2A6B8"];
const UNITS = ["byte", "kilobyte", "megabyte", "gigabyte", "terabyte", "petabyte"] as const;
/** Decimal byte formatting through Intl's unit style (e.g. "45.2 GB"). */
export function formatBytes(bytes: number, locale = "en-US") {
let i = 0;
let n = Math.max(0, bytes);
while (n >= 1000 && i < UNITS.length - 1) {
n /= 1000;
i++;
}
return new Intl.NumberFormat(locale, {
style: "unit",
unit: UNITS[i],
unitDisplay: "short",
maximumFractionDigits: n < 10 && i > 0 ? 1 : 0,
}).format(n);
}
/**
* A stacked usage bar. Segment widths transition from their previous values when the data
* changes, hovering or tapping a segment (or focusing its legend entry) highlights it with a
* tooltip, and the total is exposed as role="meter" with a text status for the warning and
* critical thresholds. The legend doubles as the accessible breakdown list.
*/
export function StorageMeter({
segments,
capacity,
label = "Storage",
format,
locale = "en-US",
warningAt = 0.75,
criticalAt = 0.9,
showLegend = true,
thickness = 12,
gap = 2,
duration = 600,
className = "",
style,
...props
}: StorageMeterProps) {
const uid = React.useId();
const labelId = `sm-${uid}-label`;
const statusId = `sm-${uid}-status`;
const [active, setActive] = React.useState<string | null>(null);
const fmt = format ?? ((v: number) => formatBytes(v, locale));
const pctFmt = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
const used = segments.reduce((sum, s) => sum + Math.max(0, s.value), 0);
const cap = Math.max(capacity, Number.EPSILON);
const ratio = used / cap;
// Over capacity: widths are scaled to fit the track, labels keep the real numbers.
const scale = ratio > 1 ? 1 / ratio : 1;
const status =
ratio > 1 ? "over" : ratio >= criticalAt ? "critical" : ratio >= warningAt ? "warning" : "ok";
const statusText = {
ok: "Plenty of space",
warning: "Running low",
critical: "Almost full",
over: "Over capacity",
}[status];
const widths = segments.map((s) => (Math.max(0, s.value) / cap) * scale * 100);
const layout = segments.map((s, i) => ({
...s,
color: s.color ?? PALETTE[i % PALETTE.length],
left: widths.slice(0, i).reduce((a, b) => a + b, 0),
width: widths[i],
}));
const activeEntry = layout.find((s) => s.key === active);
return (
<div
className={`w-full ${className}`}
style={{ ["--sm-duration" as string]: `${duration}ms`, ...style }}
onPointerLeave={(e) => {
if (e.pointerType === "mouse") setActive(null);
}}
{...props}
>
<div className="mb-3 flex items-end justify-between gap-4">
<div className="min-w-0">
<p id={labelId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</p>
<p className="mt-1 text-[22px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-zinc-950 dark:text-white">
{fmt(used)}
<span className="ml-1.5 text-[13px] font-normal tracking-normal text-zinc-500 dark:text-zinc-400">of {fmt(capacity)}</span>
</p>
</div>
<p
id={statusId}
className={`flex shrink-0 items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] ${
status === "ok"
? "text-zinc-500 dark:text-zinc-400"
: status === "warning"
? "text-amber-600 dark:text-amber-400"
: "text-rose-600 dark:text-rose-400"
}`}
>
<span
aria-hidden="true"
className={`size-1.5 rounded-full ${status === "ok" ? "bg-emerald-500" : status === "warning" ? "bg-amber-500" : "bg-rose-500"}`}
/>
{statusText}
</p>
</div>
<div className="relative">
<div
role="meter"
aria-labelledby={labelId}
aria-describedby={statusId}
aria-valuemin={0}
aria-valuemax={capacity}
aria-valuenow={Math.min(used, capacity)}
aria-valuetext={`${fmt(used)} of ${fmt(capacity)} used (${pctFmt.format(ratio)}). ${statusText}.`}
className="relative flex w-full overflow-hidden rounded-full bg-zinc-900/[0.07] dark:bg-white/[0.08]"
style={{ height: thickness }}
>
{layout.map((s) => (
<div
key={s.key}
aria-hidden="true"
onPointerEnter={(e) => {
if (e.pointerType === "mouse") setActive(s.key);
}}
onClick={() => setActive((a) => (a === s.key ? null : s.key))}
className="relative h-full shrink-0 cursor-default transition-[flex-basis,opacity] duration-[var(--sm-duration)] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
style={{ flexBasis: `${s.width.toFixed(3)}%`, opacity: active && active !== s.key ? 0.3 : 1 }}
>
{/* The gap is cut from the segment itself, so an empty segment collapses to nothing. */}
<span className="absolute inset-y-0 left-0" style={{ right: gap, background: s.color }} />
</div>
))}
{/* Threshold ticks. */}
{[warningAt, criticalAt].map((t) =>
t > 0 && t < 1 ? (
<span
key={t}
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 w-px bg-zinc-950/25 mix-blend-multiply dark:bg-white/40 dark:mix-blend-normal"
style={{ left: `${(t * 100).toFixed(2)}%` }}
/>
) : null,
)}
</div>
{/* Tooltip for the highlighted segment. */}
{activeEntry && activeEntry.width > 0 && (
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-full z-10 mb-2.5 -translate-x-1/2 whitespace-nowrap rounded-lg border border-zinc-900/10 bg-white px-2.5 py-1.5 text-[12px] shadow-[0_10px_24px_-12px_rgba(0,0,0,0.35)] transition-[left] duration-200 dark:border-white/10 dark:bg-zinc-900 motion-reduce:transition-none"
style={{ left: `clamp(4.5rem, ${(activeEntry.left + activeEntry.width / 2).toFixed(3)}%, calc(100% - 4.5rem))` }}
>
<span className="flex items-center gap-1.5 font-medium text-zinc-900 dark:text-zinc-100">
<span className="size-2 rounded-sm" style={{ background: activeEntry.color }} />
{activeEntry.label}
</span>
<span className="tabular-nums text-zinc-500 dark:text-zinc-400">
{fmt(activeEntry.value)} · {pctFmt.format(activeEntry.value / cap)}
</span>
</div>
)}
</div>
<p className="mt-2 flex justify-between font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums text-zinc-400 dark:text-zinc-500">
<span>{pctFmt.format(Math.min(ratio, 9.99))} used</span>
<span>{ratio > 1 ? `${fmt(used - capacity)} over` : `${fmt(capacity - used)} free`}</span>
</p>
{showLegend && (
<ul aria-label={`${label} breakdown`} className="mt-4 grid grid-cols-1 gap-1 sm:grid-cols-2">
{layout.map((s) => (
<li key={s.key} className="min-w-0">
<button
type="button"
aria-pressed={active === s.key}
onFocus={() => setActive(s.key)}
onBlur={() => setActive(null)}
onPointerEnter={(e) => {
if (e.pointerType === "mouse") setActive(s.key);
}}
onClick={() => setActive((a) => (a === s.key ? null : s.key))}
className={`flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] outline-hidden transition-[background-color,opacity] duration-150 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-zinc-900 dark:focus-visible:outline-white motion-reduce:transition-none ${
active === s.key ? "bg-zinc-900/[0.05] dark:bg-white/[0.07]" : ""
} ${active && active !== s.key ? "opacity-50" : ""}`}
>
<span aria-hidden="true" className="size-2.5 shrink-0 rounded-[3px]" style={{ background: s.color }} />
<span className="min-w-0 flex-1 truncate text-zinc-800 dark:text-zinc-200">{s.label}</span>
<span className="shrink-0 tabular-nums text-zinc-500 dark:text-zinc-400">
{fmt(s.value)}
<span className="ml-1.5 inline-block w-[3.25rem] text-right font-mono text-[11px] text-zinc-400 dark:text-zinc-500">
{pctFmt.format(s.value / cap)}
</span>
</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}