Chart card
The frame a chart lives in: a title row with actions, and the loading and empty states.
Server-compatible: no hooks, no client boundary. The three states reserve the same height, so nothing jumps when data arrives — loading shows a ChartSkeleton, empty shows an EmptyState at size sm, and loading wins when both are set, since "empty" is not yet known. The card is also the surface the charts assume: they paint the gaps between stacked segments and the rings around dots in var(--card). The loading state is a Reveal: the skeleton dissolves off the chart rather than being swapped for it, and the card morphs between the two heights, so a dashboard full of charts settles instead of snapping. The card's padding reads --density-card, the same variable the stat cards beside it read.
Install
npx shadcn@latest add @vibra/chart-cardNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { AreaChart } from "@/components/ui/area-chart"
import { ChartCard } from "@/components/ui/chart-card"
import { ChartTimeRange } from "@/components/ui/chart-time-range"
const REVENUE = [
{ month: "Sep", revenue: 38200 },
{ month: "Oct", revenue: 41600 },
{ month: "Nov", revenue: 46900 },
{ month: "Dec", revenue: 52400 },
{ month: "Jan", revenue: 48700 },
{ month: "Feb", revenue: 51300 },
{ month: "Mar", revenue: 55800 },
{ month: "Apr", revenue: 54100 },
{ month: "May", revenue: 58600 },
{ month: "Jun", revenue: 61200 },
{ month: "Jul", revenue: 59800 },
{ month: "Aug", revenue: 64300 },
]
const RANGES = [
{ value: "3m", label: "3m" },
{ value: "6m", label: "6m" },
{ value: "12m", label: "12m" },
]
const MONTHS: Record<string, number> = { "3m": 3, "6m": 6, "12m": 12 }
export default function ChartCardDemo() {
const [range, setRange] = React.useState("6m")
const data = REVENUE.slice(-MONTHS[range])
return (
<div className="flex w-full flex-col gap-4">
<ChartCard
title="Monthly recurring revenue"
description="Net of refunds and credits"
actions={<ChartTimeRange value={range} onValueChange={setRange} options={RANGES} size="sm" />}
footer="Synced from billing 4 minutes ago"
height={220}
>
<AreaChart
data={data}
index="month"
series={[{ key: "revenue", label: "Revenue" }]}
valueFormatter={(value) => formatCurrency(value, "USD", { compact: true })}
height={220}
showYAxis
/>
</ChartCard>
<div className="grid gap-4 sm:grid-cols-2">
<ChartCard title="Churn by cohort" description="Loading" height={140} loading />
<ChartCard
title="Expansion revenue"
description="No upgrades this week"
emptyMessage="No upgrades in this range."
height={140}
empty
/>
</div>
</div>
)
}Tiles that drive it
A selectable KPI row re-binding the chart under it, with aria-controls between them.
"use client"
import * as React from "react"
import { formatCompact, formatPercent } from "@/lib/format"
import { AreaChart } from "@/components/ui/area-chart"
import { ChartCard } from "@/components/ui/chart-card"
import { MetricValue } from "@/components/ui/metric-value"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
const DAYS = [
{ day: "Mon", visitors: 1_820, signups: 61, rate: 0.0335 },
{ day: "Tue", visitors: 2_140, signups: 74, rate: 0.0346 },
{ day: "Wed", visitors: 2_910, signups: 108, rate: 0.0371 },
{ day: "Thu", visitors: 2_460, signups: 88, rate: 0.0358 },
{ day: "Fri", visitors: 2_180, signups: 71, rate: 0.0326 },
]
const MEASURES = {
visitors: {
label: "Visitors",
color: "chart-1" as const,
value: { kind: "count" as const, value: 11_510 },
previous: 10_240,
format: (value: number) => formatCompact(value),
},
signups: {
label: "Signups",
color: "chart-2" as const,
value: { kind: "count" as const, value: 402 },
previous: 371,
format: (value: number) => formatCompact(value),
},
rate: {
label: "Signup rate",
color: "chart-3" as const,
value: { kind: "percent" as const, value: 0.0349, precision: 2 },
previous: 0.0362,
format: (value: number) => formatPercent(value, { maximumFractionDigits: 1 }),
},
}
type MeasureKey = keyof typeof MEASURES
const CHART_ID = "chart-card-tiles-chart"
/** Tiles that drive the chart under them: a tab row, not three separate cards. */
export default function ChartCardTiles() {
const [metric, setMetric] = React.useState<MeasureKey>("visitors")
const measure = MEASURES[metric]
return (
<div className="flex w-full flex-col gap-4">
<StatCardGroup
columns={3}
selectable
// Names the region the tiles speak for, so the pair reads as one
// control rather than as a card that happens to sit above a chart: a
// row that names its panel is a tablist, each tile a tab of it.
controls={CHART_ID}
value={metric}
onValueChange={(value) => setMetric(value as MeasureKey)}
aria-label="Measure plotted below"
>
{(Object.keys(MEASURES) as MeasureKey[]).map((key) => (
<StatCard
key={key}
id={key}
label={MEASURES[key].label}
value={
<MetricValue
metric={MEASURES[key].value}
previous={MEASURES[key].previous}
label={MEASURES[key].label}
/>
}
/>
))}
</StatCardGroup>
{/* The tiles are tabs whose aria-controls name this id, so this is their
panel, named by whichever tile is selected. */}
<ChartCard
id={CHART_ID}
role="tabpanel"
aria-labelledby={metric}
title={`${measure.label}, last 5 days`}
height={220}
>
<AreaChart
data={DAYS}
index="day"
series={[{ key: metric, label: measure.label, color: measure.color }]}
height={220}
valueFormatter={measure.format}
/>
</ChartCard>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| title | React.ReactNode | — | What the chart shows — every chart needs one. |
| description | React.ReactNode | — | The range or the caveat, e.g. "Last 12 months". |
| actions | React.ReactNode | — | Top right of the header — a time range, an export menu, a link out. |
| footer | React.ReactNode | — | A muted line under the plot: a source, a freshness stamp, a note. |
| loading | boolean | false | Swaps the chart for a skeleton of the same height and sets aria-busy. |
| empty | boolean | false | Swaps the chart for an empty state; loading wins if both are set. |
| emptyMessage | React.ReactNode | No data for this range. | What the empty state says. Name the range, not the failure. |
| height | number | 280 | Height of the chart area, so all three states reserve the same space. |
Dependencies
Source
import * as React from "react"
import { ChartColumnIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { chartDefaults } from "@/components/ui/chart-core"
import { EmptyState } from "@/components/ui/empty-state"
import { ChartSkeleton } from "@/components/ui/loading-skeletons"
import { Reveal } from "@/components/ui/reveal"
// A ChartSkeleton is a legend, a plot, and a row of tick labels; the two text
// rows and their gaps take about this much, so subtracting them keeps the card
// the same height whether it is loading, empty, or plotting.
const SKELETON_CHROME = 56
// `title` is content here, not the HTML tooltip attribute, so it replaces it.
export type ChartCardProps = Omit<React.ComponentProps<"div">, "title"> & {
title: React.ReactNode
description?: React.ReactNode
/** Sits at the top right — a time range, an export menu, a link out. */
actions?: React.ReactNode
footer?: React.ReactNode
/** Swaps the chart for a skeleton of the same height and sets aria-busy. */
loading?: boolean
/** Swaps the chart for an empty state; loading wins if both are set. */
empty?: boolean
emptyMessage?: React.ReactNode
/** Height of the chart area, so all three states reserve the same space. */
height?: number
}
function ChartCard({
className,
title,
description,
actions,
footer,
loading = false,
empty = false,
emptyMessage = "No data for this range.",
height = chartDefaults.height,
children,
...props
}: ChartCardProps) {
const state = loading ? "loading" : empty ? "empty" : "ready"
return (
<Card
data-slot="chart-card"
data-state={state}
aria-busy={loading || undefined}
// The card's padding is the density token, so a chart card and the
// stat cards beside it stay on one rhythm. The literal is the
// comfortable value, for an install without the theme stylesheet.
className={cn("gap-4 [--card-spacing:var(--density-card,1rem)]", className)}
{...props}
>
<CardHeader>
<CardTitle>{title}</CardTitle>
{/* A chart's subtitle is a caption, not body copy: 13px under a 15px
title is one step, which is what the register table asks for. */}
{description ? (
<CardDescription className="text-label">{description}</CardDescription>
) : null}
{actions ? (
<CardAction className="flex items-center gap-2">{actions}</CardAction>
) : null}
</CardHeader>
<CardContent>
{/* Empty is checked first only when the answer is known: while it is
still loading, "nothing to plot" has not been established yet. */}
{empty && !loading ? (
<EmptyState
size="sm"
icon={<ChartColumnIcon />}
title={emptyMessage}
className="h-(--chart-card-height)"
style={{ "--chart-card-height": `${height}px` } as React.CSSProperties}
/>
) : (
// The skeleton dissolves off the plot rather than being swapped for
// it, and the card morphs between the two heights — so a dashboard
// full of charts settles instead of snapping.
<Reveal
ready={!loading}
skeleton={<ChartSkeleton height={Math.max(80, height - SKELETON_CHROME)} />}
>
{children}
</Reveal>
)}
</CardContent>
{footer ? (
<CardFooter className="text-xs text-muted-foreground">{footer}</CardFooter>
) : null}
</Card>
)
}
export { ChartCard }import * as React from "react"
import { formatNumber } from "@/lib/format"
import type { ChartConfig } from "@/components/ui/chart"
import { isChartToken, type ChartToken } from "@/components/ui/percentage-bar"
/** One point of a chart: the index value plus one number per series key. */
export type ChartDatum = Record<string, string | number | null | undefined>
/** One plotted measure — which key to read, what to call it, what to paint it. */
export type ChartSeries = {
key: string
label: string
/** A chart token, or any CSS colour for a brand hue. Defaults to the palette in order. */
color?: ChartToken | string
/**
* The key holding the same measure over the period before this one. Drawn as
* a dashed ghost behind the series and printed in its tooltip row as a delta.
*/
compareKey?: string
/**
* The colour the ghost is drawn in: a chart token or any CSS colour. Left
* out, the ghost wears the series' own colour; `var(--chart-neutral)` keeps
* the period before out of the palette, grey under every preset — including
* one whose first slot follows a coloured accent.
*/
compareColor?: ChartToken | string
}
/** The props every cartesian chart in the set accepts. */
export type CommonChartProps = React.ComponentProps<"div"> & {
data: ChartDatum[]
/** The key holding each point's category — the month, the day, the source. */
index: string
series: ChartSeries[]
/** Formats every number the chart prints: axis ticks, tooltips, and the text summary. */
valueFormatter?: (n: number) => string
/** Formats every index label, e.g. an ISO date into "Sep 4". */
indexFormatter?: (v: string | number) => string
/** Plot height in pixels, axis band included. */
height?: number
showLegend?: boolean
showGrid?: boolean
showXAxis?: boolean
showYAxis?: boolean
showTooltip?: boolean
/** Where the series are named: beside their last point, under the plot, or nowhere. */
legend?: ChartLegendPlacement
/** Goal lines, event markers and bands drawn over the plot and read out as text. */
annotations?: ChartAnnotation[]
/** Draws each series' `compareKey` as a dashed ghost behind it, in the series' `compareColor` if it names one, its own colour if not. */
compare?: boolean
className?: string
}
// The palette wraps rather than inventing a ninth hue: past eight series the
// colours stop being distinguishable, so fold the tail into an "Other" series.
const PALETTE_SIZE = 8
/**
* A series' colour: a chart token becomes its CSS variable, any other string is
* taken as a raw CSS colour, and a series that names none takes the next slot in
* the palette, wrapping past the eighth.
*/
export function seriesColor(series: ChartSeries, i: number): string {
const color = series.color
if (color === undefined) return `var(--chart-${(i % PALETTE_SIZE) + 1})`
return isChartToken(color) ? `var(--${color})` : color
}
/**
* The ChartConfig the chart primitive needs — it turns each entry into a
* `--color-<key>` custom property that recharts marks reference.
*/
export function buildChartConfig(series: ChartSeries[]): ChartConfig {
// fromEntries defines own properties, so a series keyed "__proto__" lands as
// data instead of reassigning the config object's prototype.
return Object.fromEntries(
series.map((entry, i) => [entry.key, { label: entry.label, color: seriesColor(entry, i) }])
)
}
/**
* The shared starting point: a 280px plot with both axes, a grid, direct labels
* at the end of each series, and a tooltip. The value axis is on — a chart
* without a printed scale is a shape, not a measurement — and the charts turn it
* off themselves when the plot is too small to carry one (`fitsYAxis`).
*/
export const chartDefaults = {
height: 280,
showLegend: true,
showGrid: true,
showXAxis: true,
showYAxis: true,
showTooltip: true,
legend: "inline-end",
} as const
/**
* A drawn line: 2px with round caps and joins reads as a stroke at any size
* and holds its own on a white sheet, where 1.5px thinned to a hair on a
* high-density screen.
*/
export const STROKE_WIDTH = 2
/**
* The gridline: the grid token, dashed, so a rule under the data reads as a
* guide rather than as a border. Every cartesian chart spreads this onto its
* CartesianGrid.
*/
export const GRID_PROPS = { stroke: "var(--chart-grid)", strokeDasharray: "3 3" } as const
/**
* The dot under the pointer: 3.5px of the series' own colour, ringed in the
* surface it sits on so it stays legible where two lines cross.
* --chart-surface falls back to the card a chart normally lives on; set it on
* any ancestor when the chart sits on another plane.
*/
export const ACTIVE_DOT = { r: 3.5, strokeWidth: 2, stroke: "var(--chart-surface, var(--card))" } as const
/**
* The corner a column turns, and the gap between two segments of one stack. A
* stack is rounded as one shape — the top corners on the topmost segment, the
* bottom corners on the lowest — so a bar reads as a printed block rather than
* a pile of separately rounded tiles.
*/
export const BAR_RADIUS = 6
export const BAR_GAP = 1
/** Axis chrome: no rule and no tick marks, so only the labels carry the scale. */
export const axisProps = { tickLine: false, axisLine: false, tickMargin: 8, fontSize: 12 } as const
/**
* The paint every axis label takes: --chart-axis, the token §3.8 names for the
* printed scale. recharts writes fill="#666" onto its own
* tick text, and the primitive's `.recharts-cartesian-axis-tick text` rule does
* not reach it — recharts 3 nests labels under
* `.recharts-cartesian-axis-tick-labels` instead — so #666 survived on the card
* at 3.16:1 in dark. Passing the fill as a tick prop puts it on the element
* itself, where nothing has to match a selector.
*/
export const AXIS_TICK = { fill: "var(--chart-axis)" } as const
/** Chart numbers fall back to thousands-separated values when no valueFormatter is given. */
export function defaultValueFormatter(value: number): string {
return formatNumber(value)
}
/** Index labels print as they arrive unless the chart is given an indexFormatter. */
export function defaultIndexFormatter(value: string | number): string {
return String(value)
}
/** What the text helpers need to turn a chart's data back into words. */
export type ChartTextOptions = {
data: ChartDatum[]
index: string
series: ChartSeries[]
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
}
function readIndex(datum: ChartDatum, index: string, format: (v: string | number) => string) {
const raw = datum[index]
return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}
function readValue(raw: ChartDatum[string], format: (n: number) => string) {
return typeof raw === "number" && Number.isFinite(raw) ? format(raw) : "no data"
}
function joinLabels(labels: string[]): string {
if (labels.length < 2) return labels.join("")
return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`
}
/**
* The plotted data as text, one row per point. Every chart renders this into a
* visually hidden list, so the numbers are readable without pointing at a tooltip.
*/
export function chartRows(options: ChartTextOptions): { label: string; readings: string }[] {
const {
data,
index,
series,
valueFormatter = defaultValueFormatter,
indexFormatter = defaultIndexFormatter,
} = options
return data.map((datum) => ({
label: readIndex(datum, index, indexFormatter),
readings: series
.map((entry) => `${entry.label} ${readValue(datum[entry.key], valueFormatter)}`)
.join(", "),
}))
}
/**
* The heading for a tooltip: the point's own index value, read straight off the
* datum. The primitive resolves its label through the config, which only works
* when the index is a string — reading the datum keeps numeric indexes intact.
*/
export function tooltipIndexLabel(
payload: ReadonlyArray<{ payload?: unknown }> | undefined,
index: string,
format: (v: string | number) => string = defaultIndexFormatter
): string {
const datum = payload?.[0]?.payload
const raw = datum && typeof datum === "object" ? (datum as ChartDatum)[index] : undefined
return typeof raw === "string" || typeof raw === "number" ? format(raw) : ""
}
/** A one-sentence description of what a chart plots, used as its accessible name. */
export function chartSummary(kind: string, options: ChartTextOptions): string {
const { data, index, series, indexFormatter = defaultIndexFormatter } = options
if (series.length === 0) return `${kind} with no series.`
const labels = joinLabels(series.map((entry) => entry.label))
if (data.length === 0) return `${kind} of ${labels} by ${index}. No data.`
const first = readIndex(data[0], index, indexFormatter)
const last = readIndex(data[data.length - 1], index, indexFormatter)
const count = `${data.length} point${data.length === 1 ? "" : "s"}`
return `${kind} of ${labels} by ${index}, ${count} from ${first} to ${last}.`
}
/* -------------------------------------------------------------------------- */
/* Annotations */
/* -------------------------------------------------------------------------- */
/** Where a chart names its series. */
export type ChartLegendPlacement = "inline-end" | "bottom" | "none"
/** The meanings an annotation can carry; each resolves to a token, never a literal. */
export type ChartAnnotationTone = "neutral" | "brand" | "positive" | "negative" | "warning" | "info"
export type ChartAnnotation =
/** A threshold across the plot: a goal, a limit, an included allowance. */
| { kind: "line"; axis?: "x" | "y"; value: number | string; label: string; tone?: ChartAnnotationTone }
/** A moment on the index axis: a launch, a deploy, an incident. */
| { kind: "event"; x: number | string; label: string; tone?: ChartAnnotationTone; href?: string }
/**
* A stretch of the index axis: a freeze, an outage, a campaign. The label
* reads from the band's start; `align: "end"` hangs it from the band's end
* instead, for a band that runs to the edge of the plot — a 59px word over
* a 30px band at the right edge otherwise runs out of the plot.
*/
| {
kind: "band"
from: number | string
to: number | string
label: string
tone?: ChartAnnotationTone
align?: "start" | "end"
}
const ANNOTATION_PAINT: Record<ChartAnnotationTone, string> = {
neutral: "var(--faint-foreground)",
brand: "var(--brand)",
positive: "var(--chart-positive)",
negative: "var(--chart-negative)",
warning: "var(--warning)",
info: "var(--info)",
}
/**
* The paint a *meaning* takes, as opposed to a category.
*
* A series that is coded by status — 200/429/500, up/down, paid/overdue — is
* not one of eight interchangeable hues: it has to resolve to the semantic
* tokens, or a reader learns the wrong colour for "failed" on one page and
* carries it to the next. Categories keep the palette; meanings come from here.
*/
export const CHART_TONES = {
positive: "var(--chart-positive)",
negative: "var(--chart-negative)",
neutral: "var(--chart-neutral)",
warning: "var(--warning)",
info: "var(--info)",
} as const
export type ChartTone = keyof typeof CHART_TONES
export function chartTone(tone: ChartTone): string {
return CHART_TONES[tone]
}
/** The token an annotation's tone paints in. */
export function annotationPaint(tone: ChartAnnotationTone = "neutral"): string {
return ANNOTATION_PAINT[tone] ?? ANNOTATION_PAINT.neutral
}
/** The numbers an annotation pins to the value axis, so the scale can hold them. */
export function annotationValues(annotation: ChartAnnotation): number[] {
if (annotation.kind === "line" && annotation.axis !== "x" && typeof annotation.value === "number")
return [annotation.value]
return []
}
/**
* Every annotation as a line of text, appended to a chart's visually hidden
* rows: a goal line a sighted reader can see has to be readable too.
*/
export function annotationRows(
annotations: ChartAnnotation[] = [],
options: {
valueFormatter?: (n: number) => string
indexFormatter?: (v: string | number) => string
} = {}
): string[] {
const {
valueFormatter = defaultValueFormatter,
indexFormatter = defaultIndexFormatter,
} = options
const at = (value: number | string) =>
typeof value === "number" ? valueFormatter(value) : indexFormatter(value)
return annotations.map((annotation) => {
if (annotation.kind === "line")
return `${annotation.label}: ${
annotation.axis === "x" ? indexFormatter(annotation.value) : at(annotation.value)
}`
if (annotation.kind === "event") return `${annotation.label} at ${indexFormatter(annotation.x)}`
return `${annotation.label}: ${indexFormatter(annotation.from)} to ${indexFormatter(annotation.to)}`
})
}