Usage line
Today's quotas in one quiet row, each a figure and a small meter that turns amber near its limit, with the plan and reset time at the end.
components/ikada/blocks/usage-line/usage-line.tsx
"use client";
/**
* Today's quotas in one quiet row, each a figure and a small meter that turns amber
* near its limit, with the plan and reset time at the end. The whole row links to a usage page.
*/
import { Meter, usageState } from "@/components/ikada/meter";
import type { MeterTone } from "@/components/ikada/meter";
import { Skeleton } from "@/components/ikada/skeleton";
import { cn, focusRing } from "@/components/ikada/lib/cn";
import { formatBytes, formatCompact } from "@/components/ikada/lib/format";
import { ChevronRightIcon, GaugeIcon } from "@/components/ikada/lib/icons";
export interface UsageQuota {
id: string;
label: string;
used: number;
limit: number;
/** How the figure reads: a count ("1.2M / 3M"), bytes ("640 MB / 1 GB") or a share of the limit ("68%"). */
unit: "count" | "bytes" | "percent";
}
export interface UsageLineProps {
quotas?: UsageQuota[];
/** Shown at the end of the row, such as the plan and when the quotas reset. */
note?: string;
/** Where the row links to. */
href?: string;
/** Draws placeholders while usage loads. */
loading?: boolean;
className?: string;
}
/** Sample data: a Free plan account two thirds through its day, storage nearly full. */
export const sampleQuotas: UsageQuota[] = [
{ id: "requests", label: "Requests", used: 61_400, limit: 100_000, unit: "count" },
{ id: "cpu", label: "CPU time", used: 0.42, limit: 1, unit: "percent" },
{ id: "storage", label: "Storage", used: 912_000_000, limit: 1_000_000_000, unit: "bytes" },
];
function figure(q: UsageQuota): string {
if (q.unit === "percent") return `${Math.round((q.used / q.limit) * 100)}%`;
if (q.unit === "bytes") return `${formatBytes(q.used)} / ${formatBytes(q.limit)}`;
return `${formatCompact(q.used)} / ${formatCompact(q.limit)}`;
}
function tone(q: UsageQuota): MeterTone {
const state = usageState(q.used, q.limit);
return state === "near" || state === "at" ? "warning" : "normal";
}
function Quota({ quota }: { quota: UsageQuota }) {
const value = figure(quota);
const t = tone(quota);
return (
<span className="inline-flex max-w-full items-center gap-2 whitespace-nowrap">
<span className="min-w-0 truncate text-fg-muted" title={quota.label}>
{quota.label}
</span>
<span className={cn("shrink-0 font-semibold tabular-nums", t === "warning" && "text-warn-fg")}>{value}</span>
<Meter
aria-label={`${quota.label}: ${value}`}
value={Math.min(quota.used, quota.limit)}
max={quota.limit}
tone={t}
valueLabel={null}
className="w-14 shrink-0"
/>
</span>
);
}
export function UsageLine({
quotas = sampleQuotas,
note = "Free plan · resets 00:00 UTC",
href = "#usage",
loading = false,
className,
}: UsageLineProps) {
return (
<a
href={href}
className={cn(
"group @container block rounded-xl bg-surface px-4 py-3 text-sb-sm text-fg shadow-card",
"transition-shadow duration-(--sb-duration-hover) ease-hover hover:shadow-float",
focusRing,
className,
)}
>
{/* Container queries, not breakpoints: the row wraps to fit wherever it is placed. */}
<span className="flex flex-wrap items-center gap-x-6 gap-y-2.5">
<span className="inline-flex items-center gap-2 font-semibold">
<GaugeIcon className="text-fg-muted" />
Today
</span>
{loading ? <Skeleton className="h-4 w-[min(28rem,60%)]" /> : quotas.map((q) => <Quota key={q.id} quota={q} />)}
<span className="inline-flex items-center gap-1 text-fg-muted @max-3xl:basis-full @3xl:ms-auto">
{note}
<ChevronRightIcon className="transition-transform duration-(--sb-duration-hover) group-hover:translate-x-0.5" />
<span className="sr-only">. Open the usage page</span>
</span>
</span>
</a>
);
}"use client";
/**
* Today's quotas in one quiet row, each a figure and a small meter that turns amber
* near its limit, with the plan and reset time at the end. The whole row links to a usage page.
*/
import { Meter, usageState } from "@/components/ikada/meter";
import type { MeterTone } from "@/components/ikada/meter";
import { Skeleton } from "@/components/ikada/skeleton";
import { cn, focusRing } from "@/components/ikada/lib/cn";
import { formatBytes, formatCompact } from "@/components/ikada/lib/format";
import { ChevronRightIcon, GaugeIcon } from "@/components/ikada/lib/icons";
export interface UsageQuota {
id: string;
label: string;
used: number;
limit: number;
/** How the figure reads: a count ("1.2M / 3M"), bytes ("640 MB / 1 GB") or a share of the limit ("68%"). */
unit: "count" | "bytes" | "percent";
}
export interface UsageLineProps {
quotas?: UsageQuota[];
/** Shown at the end of the row, such as the plan and when the quotas reset. */
note?: string;
/** Where the row links to. */
href?: string;
/** Draws placeholders while usage loads. */
loading?: boolean;
className?: string;
}
/** Sample data: a Free plan account two thirds through its day, storage nearly full. */
export const sampleQuotas: UsageQuota[] = [
{ id: "requests", label: "Requests", used: 61_400, limit: 100_000, unit: "count" },
{ id: "cpu", label: "CPU time", used: 0.42, limit: 1, unit: "percent" },
{ id: "storage", label: "Storage", used: 912_000_000, limit: 1_000_000_000, unit: "bytes" },
];
function figure(q: UsageQuota): string {
if (q.unit === "percent") return `${Math.round((q.used / q.limit) * 100)}%`;
if (q.unit === "bytes") return `${formatBytes(q.used)} / ${formatBytes(q.limit)}`;
return `${formatCompact(q.used)} / ${formatCompact(q.limit)}`;
}
function tone(q: UsageQuota): MeterTone {
const state = usageState(q.used, q.limit);
return state === "near" || state === "at" ? "warning" : "normal";
}
function Quota({ quota }: { quota: UsageQuota }) {
const value = figure(quota);
const t = tone(quota);
return (
<span className="inline-flex max-w-full items-center gap-2 whitespace-nowrap">
<span className="min-w-0 truncate text-fg-muted" title={quota.label}>
{quota.label}
</span>
<span className={cn("shrink-0 font-semibold tabular-nums", t === "warning" && "text-warn-fg")}>{value}</span>
<Meter
aria-label={`${quota.label}: ${value}`}
value={Math.min(quota.used, quota.limit)}
max={quota.limit}
tone={t}
valueLabel={null}
className="w-14 shrink-0"
/>
</span>
);
}
export function UsageLine({
quotas = sampleQuotas,
note = "Free plan · resets 00:00 UTC",
href = "#usage",
loading = false,
className,
}: UsageLineProps) {
return (
<a
href={href}
className={cn(
"group @container block rounded-xl bg-surface px-4 py-3 text-sb-sm text-fg shadow-card",
"transition-shadow duration-(--sb-duration-hover) ease-hover hover:shadow-float",
focusRing,
className,
)}
>
{/* Container queries, not breakpoints: the row wraps to fit wherever it is placed. */}
<span className="flex flex-wrap items-center gap-x-6 gap-y-2.5">
<span className="inline-flex items-center gap-2 font-semibold">
<GaugeIcon className="text-fg-muted" />
Today
</span>
{loading ? <Skeleton className="h-4 w-[min(28rem,60%)]" /> : quotas.map((q) => <Quota key={q.id} quota={q} />)}
<span className="inline-flex items-center gap-1 text-fg-muted @max-3xl:basis-full @3xl:ms-auto">
{note}
<ChevronRightIcon className="transition-transform duration-(--sb-duration-hover) group-hover:translate-x-0.5" />
<span className="sr-only">. Open the usage page</span>
</span>
</span>
</a>
);
}
Installation
npx shadcn@latest add https://sproutboat.com/ikada/r/usage-line.jsonCopies components/ikada/blocks/usage-line/usage-line.tsx into your project with the ikada theme. It is your code from then on: rename it, restyle it, and feed it your own data through its props.
Built from
Props
| Prop | Type | Description |
|---|---|---|
| quotas | UsageQuota[] | Default `[ { id: "requests", label: "Requests", used: 61_400, limit: 100_000, unit: "count" }, { id: "cpu", label: "CPU time", used: 0.42, limit: 1, unit: "percent" }, { id: "storage", label: "Storage", used: 912_000_000, limit: 1_000_000_000, unit: "bytes" }, ]`. |
| note | string | Shown at the end of the row, such as the plan and when the quotas reset. Default `Free plan · resets 00:00 UTC`. |
| href | string | Where the row links to. Default `#usage`. |
| loading | boolean | Draws placeholders while usage loads. Default `false`. |
| className | string | Classes merged onto the root element, after ikada's own. |