Charts 3: small multiples
Small multiples: one small area chart per metric with the latest value, its change over the period in words and an arrow, which direction is good news, and a written summary for screen readers.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Charts 3. https://ui.ballmac.com/blocks/charts-3
"use client"
import * as React from "react"
import { ArrowDown, ArrowUp, Gauge } from "lucide-react"
import { useReducedMotion } from "motion/react"
import { Area, AreaChart } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ballmac/chart"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"
type Charts3Metric = {
id: string
label: string
/** One value per period, oldest first. At least two. */
values: number[]
/** How the value is written. */
format?: "number" | "currency" | "percent"
/** Which direction is good news. A fall in refunds is good. */
better?: "higher" | "lower"
}
type Charts3Props = Omit<React.ComponentProps<"section">, "title"> &
StateProps & {
title?: string
description?: string
/** The metrics to show, one small chart each. */
metrics?: Charts3Metric[]
/** What one value stands for, for the captions: "week". */
period?: string
currency?: string
locale?: string
}
const sample: Charts3Metric[] = [
{ id: "revenue", label: "Revenue", format: "currency", values: [41200, 43800, 42100, 46500, 48900, 47200, 52100, 54800, 53600, 58200, 61400, 63900] },
{ id: "orders", label: "Orders", values: [812, 846, 801, 903, 951, 934, 1012, 1068, 1041, 1130, 1187, 1224] },
{ id: "visitors", label: "Visitors", values: [18400, 19200, 18900, 20100, 21400, 21000, 22800, 23500, 23100, 24900, 25800, 26700] },
{ id: "conversion", label: "Conversion", format: "percent", values: [0.0441, 0.0439, 0.0424, 0.0449, 0.0444, 0.0445, 0.0444, 0.0454, 0.0451, 0.0454, 0.046, 0.0458] },
{ id: "refunds", label: "Refund rate", format: "percent", better: "lower", values: [0.031, 0.029, 0.034, 0.03, 0.028, 0.031, 0.027, 0.026, 0.029, 0.025, 0.024, 0.023] },
{ id: "tickets", label: "Support tickets", better: "lower", values: [214, 228, 241, 236, 219, 224, 207, 198, 205, 191, 186, 179] },
]
function Skel() {
return (
<div aria-hidden="true" className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }, (_, i) => (
<Skeleton key={i} className="h-40 rounded-xl" />
))}
</div>
)
}
function Tile({ metric, period, currency, locale }: { metric: Charts3Metric; period: string; currency: string; locale: string }) {
const reduced = useReducedMotion()
const id = React.useId().replace(/:/g, "")
const write = React.useMemo(() => {
const nf =
metric.format === "currency"
? new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 })
: metric.format === "percent"
? new Intl.NumberFormat(locale, { style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1 })
: new Intl.NumberFormat(locale)
return (v: number) => nf.format(v)
}, [metric.format, locale, currency])
const change = React.useMemo(() => new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1, signDisplay: "exceptZero" }), [locale])
const first = metric.values[0]!
const last = metric.values.at(-1)!
const delta = first ? (last - first) / Math.abs(first) : 0
const good = (metric.better ?? "higher") === "higher" ? delta >= 0 : delta <= 0
const Arrow = delta >= 0 ? ArrowUp : ArrowDown
const points = metric.values.map((value, i) => ({ n: i + 1, value }))
const config = { value: { label: metric.label, color: "var(--chart-1)" } } satisfies ChartConfig
const name = `${metric.label}, last ${metric.values.length} ${period}s`
return (
<li className="bg-card min-w-0 rounded-xl border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-muted-foreground truncate text-sm font-medium">{metric.label}</h2>
<p className="mt-1 text-2xl font-semibold tracking-tight tabular-nums">{write(last)}</p>
</div>
<p className="flex items-center gap-1 text-xs font-medium tabular-nums">
<Arrow aria-hidden="true" className={cn("size-3.5", good ? "text-chart-2" : "text-destructive")} />
<span>{change.format(delta)}</span>
<span className="sr-only">{good ? ", better" : ", worse"} than {metric.values.length - 1} {period}s ago</span>
</p>
</div>
<ChartContainer
config={config}
label={name}
summary={`${metric.label} went from ${write(first)} to ${write(last)} over ${metric.values.length} ${period}s, a change of ${change.format(delta)}. The highest was ${write(Math.max(...metric.values))} and the lowest ${write(Math.min(...metric.values))}.`}
format={(v) => write(v)}
className="mt-3 aspect-auto h-16 w-full"
initialDimension={{ width: 240, height: 64 }}
>
<AreaChart data={points} margin={{ top: 4, right: 0, bottom: 0, left: 0 }} accessibilityLayer>
<defs>
<linearGradient id={`${id}-g`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-value)" stopOpacity={0.35} />
<stop offset="95%" stopColor="var(--color-value)" stopOpacity={0.02} />
</linearGradient>
</defs>
<ChartTooltip cursor={{ strokeDasharray: "3 3" }} content={<ChartTooltipContent hideLabel hideIndicator />} />
<Area dataKey="value" type="monotone" stroke="var(--color-value)" strokeWidth={2} fill={`url(#${id}-g)`} dot={false} activeDot={{ r: 3.5 }} isAnimationActive={!reduced} animationDuration={600} />
</AreaChart>
</ChartContainer>
</li>
)
}
/**
* Small multiples: one small chart per metric, all drawn the same way so they can be compared by eye. Each tile gives the latest value, its change over the
* period in words as well as an arrow (and which way counts as better, so a fall in refunds reads as good news) and a sentence for screen readers.
*/
function Charts3({
title = "Store health",
description = "Six numbers that say whether the week went well.",
metrics = sample,
period = "week",
currency = "USD",
locale = "en",
state = "ready",
onRetry,
emptyState,
errorState,
className,
...props
}: Charts3Props) {
const id = React.useId()
const list = state === "empty" ? [] : metrics.filter((m) => m.values.length >= 2)
return (
<section data-slot="charts-3" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-5xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}>
<header className="mb-8">
<h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance">
{title}
</h1>
<p className="text-muted-foreground mt-2 max-w-2xl text-pretty">{description}</p>
</header>
<StateView
state={state === "empty" || !list.length ? "empty" : state === "loading" || state === "error" ? state : "ready"}
onRetry={onRetry}
label="Loading metrics"
loading={<Skel />}
empty={{ icon: <Gauge />, title: emptyState?.title ?? "No metrics yet", description: emptyState?.description ?? "Once there is a second period to compare, each metric gets a chart here.", action: emptyState?.action }}
error={{ title: errorState?.title ?? "Metrics could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}
>
<ul role="list" className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{list.map((m) => (
<Tile key={m.id} metric={m} period={period} currency={currency} locale={locale} />
))}
</ul>
</StateView>
</section>
)
}
export { Charts3, type Charts3Props, type Charts3Metric }Installation
$ pnpm dlx shadcn@latest add @ballmac/charts-3Install the dependencies.
$ pnpm add lucide-react motion@^12 recharts@^3Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/chart @ballmac/skeleton @ballmac/state-viewCopy the source into your project.
components/ballmac/blocks/charts-3/charts-3.tsx// Ballmac UI: Charts 3. https://ui.ballmac.com/blocks/charts-3 "use client" import * as React from "react" import { ArrowDown, ArrowUp, Gauge } from "lucide-react" import { useReducedMotion } from "motion/react" import { Area, AreaChart } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ballmac/chart" import { Skeleton } from "@/components/ballmac/skeleton" import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view" import { cn } from "@/lib/utils" type Charts3Metric = { id: string label: string /** One value per period, oldest first. At least two. */ values: number[] /** How the value is written. */ format?: "number" | "currency" | "percent" /** Which direction is good news. A fall in refunds is good. */ better?: "higher" | "lower" } type Charts3Props = Omit<React.ComponentProps<"section">, "title"> & StateProps & { title?: string description?: string /** The metrics to show, one small chart each. */ metrics?: Charts3Metric[] /** What one value stands for, for the captions: "week". */ period?: string currency?: string locale?: string } const sample: Charts3Metric[] = [ { id: "revenue", label: "Revenue", format: "currency", values: [41200, 43800, 42100, 46500, 48900, 47200, 52100, 54800, 53600, 58200, 61400, 63900] }, { id: "orders", label: "Orders", values: [812, 846, 801, 903, 951, 934, 1012, 1068, 1041, 1130, 1187, 1224] }, { id: "visitors", label: "Visitors", values: [18400, 19200, 18900, 20100, 21400, 21000, 22800, 23500, 23100, 24900, 25800, 26700] }, { id: "conversion", label: "Conversion", format: "percent", values: [0.0441, 0.0439, 0.0424, 0.0449, 0.0444, 0.0445, 0.0444, 0.0454, 0.0451, 0.0454, 0.046, 0.0458] }, { id: "refunds", label: "Refund rate", format: "percent", better: "lower", values: [0.031, 0.029, 0.034, 0.03, 0.028, 0.031, 0.027, 0.026, 0.029, 0.025, 0.024, 0.023] }, { id: "tickets", label: "Support tickets", better: "lower", values: [214, 228, 241, 236, 219, 224, 207, 198, 205, 191, 186, 179] }, ] function Skel() { return ( <div aria-hidden="true" className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> {Array.from({ length: 6 }, (_, i) => ( <Skeleton key={i} className="h-40 rounded-xl" /> ))} </div> ) } function Tile({ metric, period, currency, locale }: { metric: Charts3Metric; period: string; currency: string; locale: string }) { const reduced = useReducedMotion() const id = React.useId().replace(/:/g, "") const write = React.useMemo(() => { const nf = metric.format === "currency" ? new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }) : metric.format === "percent" ? new Intl.NumberFormat(locale, { style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1 }) : new Intl.NumberFormat(locale) return (v: number) => nf.format(v) }, [metric.format, locale, currency]) const change = React.useMemo(() => new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1, signDisplay: "exceptZero" }), [locale]) const first = metric.values[0]! const last = metric.values.at(-1)! const delta = first ? (last - first) / Math.abs(first) : 0 const good = (metric.better ?? "higher") === "higher" ? delta >= 0 : delta <= 0 const Arrow = delta >= 0 ? ArrowUp : ArrowDown const points = metric.values.map((value, i) => ({ n: i + 1, value })) const config = { value: { label: metric.label, color: "var(--chart-1)" } } satisfies ChartConfig const name = `${metric.label}, last ${metric.values.length} ${period}s` return ( <li className="bg-card min-w-0 rounded-xl border p-4"> <div className="flex items-start justify-between gap-3"> <div className="min-w-0"> <h2 className="text-muted-foreground truncate text-sm font-medium">{metric.label}</h2> <p className="mt-1 text-2xl font-semibold tracking-tight tabular-nums">{write(last)}</p> </div> <p className="flex items-center gap-1 text-xs font-medium tabular-nums"> <Arrow aria-hidden="true" className={cn("size-3.5", good ? "text-chart-2" : "text-destructive")} /> <span>{change.format(delta)}</span> <span className="sr-only">{good ? ", better" : ", worse"} than {metric.values.length - 1} {period}s ago</span> </p> </div> <ChartContainer config={config} label={name} summary={`${metric.label} went from ${write(first)} to ${write(last)} over ${metric.values.length} ${period}s, a change of ${change.format(delta)}. The highest was ${write(Math.max(...metric.values))} and the lowest ${write(Math.min(...metric.values))}.`} format={(v) => write(v)} className="mt-3 aspect-auto h-16 w-full" initialDimension={{ width: 240, height: 64 }} > <AreaChart data={points} margin={{ top: 4, right: 0, bottom: 0, left: 0 }} accessibilityLayer> <defs> <linearGradient id={`${id}-g`} x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor="var(--color-value)" stopOpacity={0.35} /> <stop offset="95%" stopColor="var(--color-value)" stopOpacity={0.02} /> </linearGradient> </defs> <ChartTooltip cursor={{ strokeDasharray: "3 3" }} content={<ChartTooltipContent hideLabel hideIndicator />} /> <Area dataKey="value" type="monotone" stroke="var(--color-value)" strokeWidth={2} fill={`url(#${id}-g)`} dot={false} activeDot={{ r: 3.5 }} isAnimationActive={!reduced} animationDuration={600} /> </AreaChart> </ChartContainer> </li> ) } /** * Small multiples: one small chart per metric, all drawn the same way so they can be compared by eye. Each tile gives the latest value, its change over the * period in words as well as an arrow (and which way counts as better, so a fall in refunds reads as good news) and a sentence for screen readers. */ function Charts3({ title = "Store health", description = "Six numbers that say whether the week went well.", metrics = sample, period = "week", currency = "USD", locale = "en", state = "ready", onRetry, emptyState, errorState, className, ...props }: Charts3Props) { const id = React.useId() const list = state === "empty" ? [] : metrics.filter((m) => m.values.length >= 2) return ( <section data-slot="charts-3" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-5xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}> <header className="mb-8"> <h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance"> {title} </h1> <p className="text-muted-foreground mt-2 max-w-2xl text-pretty">{description}</p> </header> <StateView state={state === "empty" || !list.length ? "empty" : state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading metrics" loading={<Skel />} empty={{ icon: <Gauge />, title: emptyState?.title ?? "No metrics yet", description: emptyState?.description ?? "Once there is a second period to compare, each metric gets a chart here.", action: emptyState?.action }} error={{ title: errorState?.title ?? "Metrics could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }} > <ul role="list" className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> {list.map((m) => ( <Tile key={m.id} metric={m} period={period} currency={currency} locale={locale} /> ))} </ul> </StateView> </section> ) } export { Charts3, type Charts3Props, type Charts3Metric }Update the import paths to match your project setup.
Usage
"use client"
import { Charts3 } from "@/components/ballmac/blocks/charts-3/charts-3"
export default function Charts3Demo() {
return <Charts3 />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
Six placeholder tiles under the real heading, announced as loading.
state="loading"No metrics yet
Says each metric gets a chart once there is a second period; emptyState sets the copy.
state="empty" (or metrics={[]})Could not load
An alert with Try again (onRetry).
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 4 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
7 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, empty, error states, each with an example.
Passed: Automated tests
Covered by charts-3.test.tsx.
For information: Footprint
3.1 KB of source (gzip) in 1 file. Installs lucide-react, motion, recharts.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
Use with AI
<Charts3 title description metrics={[{ id, label, values, format?, better? }]} period='week' currency locale state />. values are one number per period, oldest first. The latest value and the change from the first value are worked out. format: number | currency | percent (percent values are fractions: 0.045 is 4.5%). better: 'lower' makes a fall count as good news. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Charts 3: small multiples (@ballmac/charts-3) to this project with the shadcn MCP, then use it where it fits.
Use it for
- An at-a-glance health page
- Comparing many metrics that share a period
Not for
- One metric in depth (use chart-area or chart-line)
- Comparing metrics on one scale (use chart-line with several series)
Registry JSON: https://ui.ballmac.com/r/charts-3.json
Free to use in personal and commercial projects.
More charts blocks
Charts 1: analytics overview
An analytics overview: a range picker, four figures with their change from the period before, a stacked area chart of new and returning visitors, top pages and a sources donut, each with a table view and CSV download.
Charts 2: revenue report
A revenue report: monthly recurring revenue, annual run rate, what was added last month and the largest plan, then revenue by plan as stacked columns and net revenue retention as a line against the 100% mark.
Charts 4: scorecard
A quarterly scorecard: goals met and average progress, concentric rings for the goals, a radar of the team against its targets and a ranked bar chart, with a table view and CSV download on every chart.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.