Product
Usage card
Dashboard usage chart with breakdown selection, date ranges, stacked bars, and series filtering.
$ npx context-ds add usage-cardUsage
Use it for
Usage charts on dashboard overview pages.
Avoid
- A separate title above the menu
- Losing the picked series when switching breakdowns
Dependencies: bar-chart, glide-tabs, popover, skeleton, glyph-tile, glyph-engine, lucide-react
Example
examples/usage-card.tsx
"use client";import { useState } from "react";import type { ChartBucket } from "@/components/product/bar-chart";import { UsageCard, UsageCardEmpty } from "@/components/product/usage-card";const SERIES = { status: [ { key: "200", label: "200", color: "var(--ds-color-chart-200)" }, { key: "404", label: "404", color: "var(--ds-color-chart-404)" }, { key: "429", label: "429", color: "var(--ds-color-chart-rate-limited)" }, ], auth: [ { key: "valid", label: "Valid", color: "var(--ds-color-chart-ok)" }, { key: "rateLimited", label: "Rate limited", color: "var(--ds-color-chart-rate-limited)" }, ],};function bucketsFor(keys: string[], days: number): ChartBucket[] { return Array.from({ length: days }, (_, i) => { const base = Math.round(700 + Math.sin(i / 2) * 220 + i * 12); const values = Object.fromEntries(keys.map((k, j) => [k, Math.round(base * (j === 0 ? 0.9 : 0.1 / (keys.length - 1)))])); return { label: new Date(2026, 9, 17 - (days - 1 - i)).toLocaleDateString("en-US", { month: "short", day: "numeric" }), values, total: Object.values(values).reduce((a, b) => a + b, 0) }; });}const BREAKDOWNS = [ { value: "status" as const, label: "Status", description: "Calls by response status", glyph: "status" as const }, { value: "auth" as const, label: "Auth", description: "Calls by key check result", glyph: "auth" as const },];const RANGES = [ { value: "7" as const, label: "Last 7 days", short: "7D" }, { value: "30" as const, label: "Last 30 days", short: "30D" },];export function Product() { const [breakdown, setBreakdown] = useState<"status" | "auth">("status"); const [range, setRange] = useState<"7" | "30">("30"); const series = SERIES[breakdown]; return ( <div className="w-full max-w-xl"> <UsageCard breakdowns={BREAKDOWNS} breakdown={breakdown} onBreakdownChange={setBreakdown} ranges={RANGES} range={range} onRangeChange={setRange} view={{ buckets: bucketsFor(series.map((s) => s.key), Number(range)), series, unit: "calls", label: `API calls by ${breakdown}, last ${range} days` }} /> </div> );}export function Empty() { const [breakdown, setBreakdown] = useState<"status" | "auth">("status"); const [range, setRange] = useState<"7" | "30">("30"); return ( <div className="w-full max-w-xl"> <UsageCard breakdowns={BREAKDOWNS} breakdown={breakdown} onBreakdownChange={setBreakdown} ranges={RANGES} range={range} onRangeChange={setRange} view={{ buckets: [], series: [], unit: "calls", label: "API calls" }} empty={ <UsageCardEmpty> Run one above or in the{" "} <a href="/preview/try-api/Product" className="text-fg-brand hover:underline"> playground </a>{" "} and your usage fills in here. </UsageCardEmpty> } /> </div> );}Source
components/product/usage-card.tsx
"use client";import * as React from "react";import { Check, ChevronDown } from "lucide-react";import { BarChart, type ChartBucket, type ChartSeries, type SeriesSelectMode } from "@/components/product/bar-chart";import { GlideTabs } from "@/components/product/glide-tabs";import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";import { Skeleton } from "@/components/ui/skeleton";import { StillGlyph } from "@/components/visuals/glyph/glyph-tile";import type { GlyphPairName } from "@/components/visuals/glyph/glyph-art";import { cn } from "@/lib/utils";export type UsageBreakdown<B extends string = string> = { value: B; label: string; description: string; glyph: GlyphPairName };export type UsageRange<R extends string = string> = { value: R; label: string; short: string };export type UsageView = { buckets: ChartBucket[]; series: ChartSeries[]; unit: string; label: string };// The overview's usage card: one stacked bar chart for the picked breakdown. The card's title is its menu (the// breakdown's glyph and name, opening the others with a line each); the range sits at the right on a gliding pill,// its names shortened to 7D, 30D and 3M when the card is narrow; the legend under the chart picks series out, and// each breakdown keeps its own pick, so switching away and back keeps it. A metric switch can sit at the legend's end.function UsageCard<B extends string, R extends string>({ breakdowns, breakdown, onBreakdownChange, ranges, range, onRangeChange, view, legendAside, loading = false, empty, className,}: { breakdowns: UsageBreakdown<B>[]; breakdown: B; onBreakdownChange: (value: B) => void; ranges: UsageRange<R>[]; range: R; onRangeChange: (value: R) => void; /** The chart for the picked breakdown and range. */ view: UsageView; legendAside?: React.ReactNode; loading?: boolean; /** Shown instead of the chart when there's no usage yet. */ empty?: React.ReactNode; className?: string;}) { const [picks, setPicks] = React.useState<Partial<Record<B, string[]>>>({}); const seriesKeys = new Set(view.series.map((s) => s.key)); const picked = (picks[breakdown] ?? []).filter((k) => seriesKeys.has(k)); const pickedKeys = new Set(picked); const fadedKeys = new Set(picked.length ? view.series.map((s) => s.key).filter((k) => !pickedKeys.has(k)) : []); const select = (key: string, mode: SeriesSelectMode) => setPicks((p) => ({ ...p, [breakdown]: mode === "toggle" ? (picked.includes(key) ? picked.filter((k) => k !== key) : [...picked, key]) : picked.length === 1 && picked[0] === key ? [] : [key] })); return ( <section data-slot="usage-card" className={cn("@container flex h-64 flex-col rounded-card bg-surface p-2 shadow-card", className)}> <div className="mb-3 flex items-center gap-3"> <BreakdownPicker options={breakdowns} value={breakdown} onValueChange={onBreakdownChange} /> <GlideTabs label="Date range" className="ml-auto" value={range} onValueChange={onRangeChange} options={ranges.map((r) => ({ value: r.value, ariaLabel: r.label, label: ( <> <span className="@2xl:hidden">{r.short}</span> <span className="hidden @2xl:inline">{r.label}</span> </> ), }))} /> </div> {loading ? ( <Skeleton className="flex-1 rounded-card" /> ) : empty ? ( <div className="flex flex-1 flex-col items-center justify-center gap-1.5 text-center">{empty}</div> ) : ( <BarChart buckets={view.buckets} series={view.series} unit={view.unit} label={view.label} fadedKeys={fadedKeys} onSeriesSelect={select} legendAside={legendAside} /> )} </section> );}/** The "no API calls yet" line for the card's empty state. */function UsageCardEmpty({ title = "No API calls yet", children }: { title?: React.ReactNode; children?: React.ReactNode }) { return ( <> <p className="text-body-sm text-fg">{title}</p> {children && <p className="max-w-xs text-caption text-fg-muted">{children}</p>} </> );}/** A card title that doubles as its menu: the glyph and name, opening a list of the options with a line each. */function BreakdownPicker<B extends string>({ options, value, onValueChange, label = "Break usage down by" }: { options: UsageBreakdown<B>[]; value: B; onValueChange: (value: B) => void; label?: string }) { const [open, setOpen] = React.useState(false); const selected = options.find((o) => o.value === value) ?? options[0]; return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <button type="button" aria-haspopup="listbox" aria-label={`${label}: ${selected.label}`} className="flex h-8 shrink-0 items-center gap-2 rounded-control pr-2 pl-1.5 outline-none transition-colors duration-150 hover:bg-wash-hover focus-visible:ring-2 focus-visible:ring-focus-ring data-[state=open]:bg-wash-hover" > {/* Status's bars stand on their bottom row, so they sit low beside the label. */} <StillGlyph name={selected.glyph} dense className={cn("size-4 shrink-0 text-brand", selected.glyph === "status" && "-translate-y-px")} /> <span className="text-body-sm leading-none">{selected.label}</span> <ChevronDown className="size-3.5 text-fg-subtle" strokeWidth={1.5} /> </button> </PopoverTrigger> <PopoverContent align="start" className="w-72 p-(--ds-space-frame)"> <ul role="listbox" aria-label={label} className="flex flex-col gap-0.5"> {options.map((o) => { const isSelected = o.value === value; return ( <li key={o.value}> <button type="button" role="option" aria-selected={isSelected} onClick={() => { onValueChange(o.value); setOpen(false); }} className={cn("relative flex w-full items-center gap-2 rounded-control px-2.5 py-1.5 text-left leading-5 outline-none transition-colors duration-150 hover:bg-wash-hover focus-visible:bg-wash-hover", isSelected && "bg-wash-hover")} > <span className="flex size-8 shrink-0 items-center justify-center rounded-control border border-line"> <StillGlyph name={o.glyph} dense className="size-5 text-brand" /> </span> <span className="min-w-0 flex-1"> <span className="block text-body-sm text-fg">{o.label}</span> <span className="block truncate text-caption text-fg-subtle">{o.description}</span> </span> <Check className={cn("size-3.5 shrink-0 text-brand", !isSelected && "invisible")} strokeWidth={1.75} /> </button> </li> ); })} </ul> </PopoverContent> </Popover> );}export { BreakdownPicker, UsageCard, UsageCardEmpty };Reference: context-dashboard c0ddf7a src/components/overview/overview-page.tsx.