An area chart with gradient, solid or no fill, stacked or expanded to 100%, four curves and reference lines, in a frame with a table view, a legend that toggles series, patterns for color blindness and loading, empty and error states.
Visitors
Desktop and mobile, January to June
"use client"
import { ChartArea } from "@/components/ballmac/chart-area"
const data = [
{ month: "Jan", desktop: 4210, mobile: 2480 },
{ month: "Feb", desktop: 4860, mobile: 3120 },
{ month: "Mar", desktop: 4540, mobile: 3790 },
{ month: "Apr", desktop: 5320, mobile: 4650 },
{ month: "May", desktop: 5910, mobile: 5230 },
{ month: "Jun", desktop: 6480, mobile: 6140 },
]
export default function ChartAreaDemo() {
return (
<ChartArea
className="w-full max-w-2xl"
title="Visitors"
description="Desktop and mobile, January to June"
data={data}
xKey="month"
categoryLabel="Month"
series={[
{ key: "desktop", label: "Desktop" },
{ key: "mobile", label: "Mobile" },
]}
/>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/chart-areaInstall the dependencies.
$ pnpm add recharts@^3Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/chart @ballmac/chart-frameCopy the source into your project.
components/ballmac/chart-area.tsx// Ballmac UI: Chart Area. https://ui.ballmac.com/components/chart-area "use client" import * as React from "react" import { Area, AreaChart, CartesianGrid, ReferenceLine, XAxis, YAxis } from "recharts" import { ChartTooltip, ChartTooltipContent } from "@/components/ballmac/chart" import { ChartFrame, domainTicks, niceScale, type ChartFrameProps } from "@/components/ballmac/chart-frame" import { cn } from "@/lib/utils" type ChartAreaProps = Omit<ChartFrameProps, "children" | "summaryMode" | "skeleton" | "table"> & { /** How the line moves between points. */ curve?: "monotone" | "linear" | "step" | "natural" /** "stacked" adds the series up; "expanded" shows each one's share of the total, as a percentage. */ stack?: "none" | "stacked" | "expanded" /** What fills under each line. */ fillMode?: "gradient" | "solid" | "none" /** Draw a dot on every point. */ dots?: boolean /** Which grid lines to draw. */ grid?: "horizontal" | "both" | "none" /** Show the value axis. */ yAxis?: boolean /** Writes a tick on the value axis. Defaults to a compact number (12K). */ yFormatter?: (value: number) => string /** Fix the value axis, for example `[0, 100]`. */ yDomain?: [number | "auto", number | "auto"] /** Horizontal lines at fixed values, such as a target or a limit. */ referenceLines?: { y: number; label: string }[] /** The marker style in the tooltip, or "none" to hide the tooltip. */ tooltip?: "line" | "dot" | "dashed" | "none" } /** * An area chart in a ChartFrame: gradient, solid or no fill, stacked or expanded to 100%, four curves, reference lines, a legend that switches * series on and off, a Table view, patterns for color blindness, loading, empty and error states, and mirroring in right-to-left. */ function ChartArea({ data, xKey, series, curve = "monotone", stack = "none", fillMode = "gradient", dots = false, grid = "horizontal", yAxis = true, yFormatter, yDomain, referenceLines, tooltip = "line", chartClassName, ...frame }: ChartAreaProps) { const uid = React.useId().replace(/:/g, "") const expanded = stack === "expanded" return ( <ChartFrame data={data} xKey={xKey} series={series} summaryMode="trend" skeleton="area" chartClassName={cn("[&_.recharts-area-dot]:stroke-card", chartClassName)} {...frame}> {(chart) => { const compact = new Intl.NumberFormat(chart.locale, { notation: "compact", maximumFractionDigits: 1 }) const scale = expanded || yDomain ? undefined : niceScale({ data, keys: chart.visible, stacked: stack === "stacked" }) return ( <AreaChart data={data} stackOffset={expanded ? "expand" : "none"} margin={chart.margin({ top: 8, right: 8, left: 0, bottom: 0 })} accessibilityLayer> <defs> {fillMode === "gradient" ? series.map((s) => ( <linearGradient key={s.key} id={`${uid}-g-${s.key}`} x1="0" y1="0" x2="0" y2="1"> <stop offset="5%" stopColor={chart.color(s.key)} stopOpacity={stack === "none" ? 0.38 : 0.55} /> <stop offset="95%" stopColor={chart.color(s.key)} stopOpacity={stack === "none" ? 0.02 : 0.18} /> </linearGradient> )) : null} {chart.defs} </defs> <CartesianGrid vertical={grid === "both"} horizontal={grid !== "none"} /> <XAxis dataKey={xKey} tickLine={false} axisLine={false} tickMargin={8} minTickGap={24} reversed={chart.rtl} tickFormatter={(v: unknown) => chart.formatCategory(v)} padding={chart.rtl ? { left: 16, right: 8 } : { left: 8, right: 16 }} /> {yAxis ? ( <YAxis width="auto" tickLine={false} axisLine={false} tickMargin={8} orientation={chart.rtl ? "right" : "left"} domain={expanded ? [0, 1] : (yDomain ?? scale?.domain)} ticks={expanded ? undefined : (scale?.ticks ?? domainTicks(yDomain))} tickFormatter={(v: number) => (expanded ? `${Math.round(v * 100)}%` : yFormatter ? yFormatter(v) : compact.format(v))} /> ) : null} {referenceLines?.map((r) => ( <ReferenceLine key={r.label} y={r.y} stroke="var(--muted-foreground)" strokeDasharray="4 4" strokeOpacity={0.7} label={{ value: r.label, position: chart.rtl ? "insideTopLeft" : "insideTopRight", fill: "var(--muted-foreground)", fontSize: 11 }} /> ))} {tooltip !== "none" ? <ChartTooltip cursor={{ strokeDasharray: "3 3" }} content={<ChartTooltipContent indicator={tooltip} />} /> : null} {series.map((s) => ( <Area key={s.key} dataKey={s.key} type={curve} hide={chart.isHidden(s.key)} stackId={stack === "none" ? undefined : "stack"} stroke={chart.color(s.key)} strokeWidth={2} fill={fillMode === "none" ? "transparent" : chart.patterns ? chart.fill(s.key) : fillMode === "solid" ? chart.color(s.key) : `url(#${uid}-g-${s.key})`} fillOpacity={fillMode === "solid" && !chart.patterns ? (stack === "none" ? 0.18 : 0.7) : 1} dot={dots ? { r: 3, strokeWidth: 2, fill: "var(--card)" } : false} activeDot={{ r: 4 }} isAnimationActive={chart.animate} animationDuration={700} /> ))} </AreaChart> ) }} </ChartFrame> ) } export { ChartArea, type ChartAreaProps }Update the import paths to match your project setup.
Usage
import { ChartArea } from "@/components/ballmac/chart-area"The full example is in the Code tab above.
Examples
Stacked
Monthly recurring revenue
By plan, stacked
"use client"
import { ChartArea } from "@/components/ballmac/chart-area"
const data = [
{ month: "Mar", team: 6200, pro: 9400 },
{ month: "Apr", team: 7100, pro: 10800 },
{ month: "May", team: 8300, pro: 11900 },
{ month: "Jun", team: 9800, pro: 12600 },
{ month: "Jul", team: 11200, pro: 14100 },
{ month: "Aug", team: 13600, pro: 15300 },
{ month: "Sep", team: 15900, pro: 16800 },
{ month: "Oct", team: 18400, pro: 18200 },
]
export default function ChartAreaStacked() {
return (
<ChartArea
className="w-full max-w-2xl"
title="Monthly recurring revenue"
description="By plan, stacked"
data={data}
xKey="month"
categoryLabel="Month"
stack="stacked"
fillMode="solid"
curve="linear"
series={[
{ key: "pro", label: "Pro" },
{ key: "team", label: "Team" },
]}
valueFormatter={(v) => `$${v.toLocaleString("en-US")}`}
yFormatter={(v) => `$${v / 1000}k`}
footer="Team passed Pro in October."
/>
)
}Expanded to 100%
Traffic mix
Each channel's share of weekly visitors
"use client"
import { ChartArea } from "@/components/ballmac/chart-area"
const data = [
{ week: "W1", search: 3100, direct: 1900, social: 800, email: 520 },
{ week: "W2", search: 3300, direct: 1950, social: 910, email: 540 },
{ week: "W3", search: 3250, direct: 2100, social: 1250, email: 600 },
{ week: "W4", search: 3600, direct: 2080, social: 1480, email: 650 },
{ week: "W5", search: 3800, direct: 2160, social: 1390, email: 760 },
{ week: "W6", search: 4210, direct: 2200, social: 1630, email: 820 },
]
export default function ChartAreaExpanded() {
return (
<ChartArea
className="w-full max-w-2xl"
title="Traffic mix"
description="Each channel's share of weekly visitors"
data={data}
xKey="week"
categoryLabel="Week"
stack="expanded"
fillMode="solid"
series={[
{ key: "search", label: "Search" },
{ key: "direct", label: "Direct" },
{ key: "social", label: "Social" },
{ key: "email", label: "Email" },
]}
/>
)
}Step
Queue depth
Jobs waiting, sampled through the day
"use client"
import { ChartArea } from "@/components/ballmac/chart-area"
const data = [
{ hour: "00:00", jobs: 12 },
{ hour: "04:00", jobs: 8 },
{ hour: "08:00", jobs: 46 },
{ hour: "10:00", jobs: 78 },
{ hour: "12:00", jobs: 54 },
{ hour: "14:00", jobs: 91 },
{ hour: "16:00", jobs: 63 },
{ hour: "18:00", jobs: 29 },
{ hour: "22:00", jobs: 14 },
]
export default function ChartAreaStep() {
return (
<ChartArea
className="w-full max-w-2xl"
title="Queue depth"
description="Jobs waiting, sampled through the day"
data={data}
xKey="hour"
categoryLabel="Time"
curve="step"
series={[{ key: "jobs", label: "Jobs waiting" }]}
tooltip="dot"
/>
)
}Linear with dots
New signups
Per week
"use client"
import { ChartArea } from "@/components/ballmac/chart-area"
const data = [
{ week: "Sep 7", signups: 212 },
{ week: "Sep 14", signups: 248 },
{ week: "Sep 21", signups: 231 },
{ week: "Sep 28", signups: 296 },
{ week: "Oct 5", signups: 340 },
{ week: "Oct 12", signups: 322 },
]
export default function ChartAreaDots() {
return (
<ChartArea
className="w-full max-w-2xl"
title="New signups"
description="Per week"
data={data}
xKey="week"
categoryLabel="Week"
curve="linear"
dots
grid="both"
series={[{ key: "signups", label: "Signups", color: "var(--chart-2)" }]}
/>
)
}Patterns for color blindness
Requests by client
Switch Patterns on to tell the series apart without color
"use client"
import { ChartArea } from "@/components/ballmac/chart-area"
const data = [
{ day: "Mon", api: 1840, web: 1260, mobile: 720 },
{ day: "Tue", api: 2010, web: 1380, mobile: 810 },
{ day: "Wed", api: 2240, web: 1290, mobile: 940 },
{ day: "Thu", api: 2190, web: 1450, mobile: 1020 },
{ day: "Fri", api: 2480, web: 1510, mobile: 1180 },
{ day: "Sat", api: 1620, web: 980, mobile: 1340 },
{ day: "Sun", api: 1410, web: 870, mobile: 1290 },
]
export default function ChartAreaPatterns() {
return (
<ChartArea
className="w-full max-w-2xl"
title="Requests by client"
description="Switch Patterns on to tell the series apart without color"
data={data}
xKey="day"
categoryLabel="Day"
stack="stacked"
fillMode="solid"
patterns="toggle"
series={[
{ key: "api", label: "API" },
{ key: "web", label: "Web" },
{ key: "mobile", label: "Mobile" },
]}
/>
)
}Reference line and footer
Uptime
Monthly, against the 99.9% target
"use client"
import { ChartArea } from "@/components/ballmac/chart-area"
const data = [
{ month: "May", uptime: 99.91 },
{ month: "Jun", uptime: 99.97 },
{ month: "Jul", uptime: 99.82 },
{ month: "Aug", uptime: 99.99 },
{ month: "Sep", uptime: 99.95 },
{ month: "Oct", uptime: 99.98 },
]
export default function ChartAreaTarget() {
return (
<ChartArea
className="w-full max-w-2xl"
title="Uptime"
description="Monthly, against the 99.9% target"
data={data}
xKey="month"
categoryLabel="Month"
series={[{ key: "uptime", label: "Uptime", color: "var(--chart-2)" }]}
yDomain={[99.7, 100]}
yFormatter={(v) => `${v}%`}
valueFormatter={(v) => `${v}%`}
referenceLines={[{ y: 99.9, label: "Target 99.9%" }]}
footer="Above target in five of six months."
/>
)
}With a range picker
Visitors
The last 90 days
"use client"
import * as React from "react"
import { ChartArea } from "@/components/ballmac/chart-area"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
// 90 days of sample data, worked out from the day number so the server and the browser agree.
const start = Date.UTC(2026, 6, 1)
const data = Array.from({ length: 90 }, (_, i) => {
const date = new Date(start + i * 86_400_000)
const weekend = [0, 6].includes(date.getUTCDay())
return {
date: date.toISOString().slice(0, 10),
desktop: Math.round(380 + i * 3.4 + Math.sin(i / 3) * 46 - (weekend ? 90 : 0)),
mobile: Math.round(260 + i * 4.1 + Math.cos(i / 4) * 38 + (weekend ? 70 : 0)),
}
})
const label = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
export default function ChartAreaInteractive() {
const [range, setRange] = React.useState("90")
const shown = data.slice(-Number(range))
return (
<ChartArea
className="w-full max-w-3xl"
title="Visitors"
description={`The last ${range} days`}
data={shown}
xKey="date"
categoryFormatter={(v) => label.format(new Date(`${v}T00:00:00Z`))}
series={[
{ key: "desktop", label: "Desktop" },
{ key: "mobile", label: "Mobile" },
]}
aspect="wide"
actions={
<SegmentedControl size="sm" aria-label="Range" value={range} onValueChange={setRange}>
<SegmentedControlItem value="90">90 days</SegmentedControlItem>
<SegmentedControlItem value="30">30 days</SegmentedControlItem>
<SegmentedControlItem value="7">7 days</SegmentedControlItem>
</SegmentedControl>
}
/>
)
}Loading, empty and error
Orders
Per month
"use client"
import * as React from "react"
import { ChartArea } from "@/components/ballmac/chart-area"
import { SegmentedControl, SegmentedControlItem } from "@/components/ballmac/segmented-control"
const data = [
{ month: "Jan", orders: 312 },
{ month: "Feb", orders: 348 },
{ month: "Mar", orders: 401 },
{ month: "Apr", orders: 386 },
{ month: "May", orders: 452 },
{ month: "Jun", orders: 497 },
]
type View = "ready" | "loading" | "empty" | "error"
export default function ChartAreaStates() {
const [state, setState] = React.useState<View>("loading")
return (
<div className="grid w-full max-w-2xl gap-3">
<SegmentedControl size="sm" aria-label="State" value={state} onValueChange={(v) => setState(v as View)}>
<SegmentedControlItem value="ready">Ready</SegmentedControlItem>
<SegmentedControlItem value="loading">Loading</SegmentedControlItem>
<SegmentedControlItem value="empty">Empty</SegmentedControlItem>
<SegmentedControlItem value="error">Error</SegmentedControlItem>
</SegmentedControl>
<ChartArea
title="Orders"
description="Per month"
data={state === "empty" ? [] : data}
xKey="month"
series={[{ key: "orders", label: "Orders" }]}
state={state}
onRetry={() => setState("ready")}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
curveHow the line moves between points. | "monotone" | "linear" | "step" | "natural" | "monotone" |
stack"stacked" adds the series up; "expanded" shows each one's share of the total, as a percentage. | "none" | "stacked" | "expanded" | "none" |
fillModeWhat fills under each line. | "gradient" | "solid" | "none" | "gradient" |
dotsDraw a dot on every point. | boolean | false |
gridWhich grid lines to draw. | "horizontal" | "both" | "none" | "horizontal" |
yAxisShow the value axis. | boolean | true |
yFormatterWrites a tick on the value axis. Defaults to a compact number (12K). | (value: number) => string | — |
yDomainFix the value axis, for example `[0, 100]`. | [number | "auto", number | "auto"] | — |
referenceLinesHorizontal lines at fixed values, such as a target or a limit. | { y: number; label: string }[] | — |
tooltipThe marker style in the tooltip, or "none" to hide the tooltip. | "line" | "dot" | "dashed" | "none" | "line" |
y* | number | — |
label* | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Arrow keys | Move between data points (accessibilityLayer) |
| Tab, Enter / Space | Legend buttons switch series; the Chart / Table switch shows the same data as a table |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 9 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.
Not applicable: Right to left
Not mirrored by design: Recharts draws its axes in SVG coordinates, which do not mirror.
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
32 tab stops, each showing a visible change when it takes focus. 2 documented keys.
Not applicable: Loading, empty and error
Does not show data or take submissions.
Passed: Automated tests
Covered by charts-batch-families.test.tsx.
For information: Footprint
2.2 KB of source (gzip) in 1 file. Installs 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.
Use with AI
<ChartArea title description data xKey series={[{ key, label, color? }]} />. curve: monotone | linear | step | natural. stack: none | stacked | expanded (percent). fillMode: gradient | solid | none. Also dots, grid, yFormatter, yDomain, referenceLines, tooltip, legend, patterns, state and every ChartFrame prop. Colors come from the theme's chart colors in order. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Chart Area (@ballmac/chart-area) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Trends over time, especially more than one series
- Part-to-whole over time (stacked or expanded)
Not for
- Comparing a few categories (use chart-bar)
- Exact values matter more than shape (use chart-line with dots or the table view)
Registry JSON: https://ui.ballmac.com/r/chart-area.json
Credits
Free to use in personal and commercial projects.
- npm
- recharts@^3
Pairs well with
Chart Frame
The card around a Recharts chart: a Table view with CSV download, a legend that toggles series, patterns so series differ by more than color, a summary for screen readers, and loading, empty and error states.
Segmented Control
A macOS segmented control: mutually exclusive options with a selected pill that springs between segments and hairline dividers between unselected ones. A Radix radio group underneath.
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
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.