Area Chart
Interactive area chart card.
Area
Stacked, overlapping, and percent modes.
Visitors
94,700
+8.2%OrganicReferralPaid
function AreaChartDemo() {
return <AreaChartCard series={AREA_SERIES} data={AREA_DATA} ranges={AREA_RANGES} />;
}function AreaChartDemo() {
return <AreaChartCard series={AREA_SERIES} data={AREA_DATA} ranges={AREA_RANGES} />;
}Installation
npx shadcn@latest add https://boardcn.dev/r/area-chart-card.jsonnpx shadcn@latest add https://boardcn.dev/r/area-chart-card.jsonnpm packages
- recharts
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"use client";
import { useId, useMemo, useState, type HTMLAttributes } from "react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
export type AreaChartVariant = "stacked" | "overlap" | "percent";
export type AreaChartShape = "curved" | "sharp";
export interface AreaChartDatum {
label: string;
[seriesKey: string]: string | number;
}
export interface AreaChartSeries {
key: string;
label: string;
/** Any CSS color. Defaults follow BoardCN's chart tone cycle. */
color?: string;
/** Hover/active color. A custom color is mixed with black when omitted. */
activeColor?: string;
}
export interface AreaChartRange {
id: string;
label: string;
data: AreaChartDatum[];
delta?: number | string;
headline?: number;
}
/** @deprecated Prefer AreaChartRange/ranges; retained as a source-compatible alias. */
export interface AreaChartPeriod extends Omit<AreaChartRange, "id"> {
id?: string;
}
export interface AreaChartCardProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
title?: string;
data: AreaChartDatum[];
series: AreaChartSeries[];
ranges?: AreaChartRange[];
/** @deprecated Alias for ranges. */
periods?: AreaChartPeriod[];
defaultRange?: string;
defaultPeriod?: string;
onRangeChange?: (id: string) => void;
/** Static range label. Supplying it without periods renders a calendar pill. */
range?: string;
headline?: number;
delta?: number | string;
variant?: AreaChartVariant;
shape?: AreaChartShape;
tiles?: boolean;
valueFormatter?: (value: number) => string;
}
const TONES = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const integerFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
function numberAt(datum: AreaChartDatum, key: string) {
const value = datum[key];
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function compact(value: number) {
if (Math.abs(value) < 1000) return `${Math.round(value)}`;
const abbreviated = value / 1000;
return `${Number.isInteger(abbreviated) ? abbreviated : abbreviated.toFixed(1)}K`;
}
function formatDelta(delta: number | string) {
if (typeof delta === "string") return delta;
const sign = delta > 0 ? "+" : "";
return `${sign}${(delta * 100).toFixed(1)}%`;
}
function resolveSeries(series: AreaChartSeries[]) {
return series.map((item, index) => {
const tone = TONES[index % TONES.length];
const color = item.color ?? `var(--color-chart-${tone})`;
return {
...item,
color,
activeColor:
item.activeColor ??
(item.color ? `color-mix(in srgb, ${item.color} 82%, black)` : `var(--color-chart-${tone}-active)`),
};
});
}
/**
* BoardCN-compatible multi-series area card. Hover information is reflected in
* the headline and tiles; the plot intentionally has no floating tooltip.
*/
export function AreaChartCard({
title = "Visitors",
data,
series,
ranges,
periods,
defaultRange,
defaultPeriod,
onRangeChange,
range = "This year",
headline: restingHeadline,
delta,
variant = "stacked",
shape = "curved",
tiles = false,
valueFormatter = (value) => integerFormatter.format(value),
className,
...props
}: AreaChartCardProps) {
const normalizedPeriods = periods?.map((period, index) => ({
...period,
id: period.id ?? `period-${index}`,
}));
const builtInPeriods = ranges ?? normalizedPeriods;
const initialPeriod =
builtInPeriods?.find((period) =>
period.id === (defaultRange ?? defaultPeriod) || period.label === (defaultRange ?? defaultPeriod),
)?.id ??
builtInPeriods?.[0]?.id;
const [periodId, setPeriodId] = useState(initialPeriod);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [activeSeries, setActiveSeries] = useState<string | null>(null);
const gradientPrefix = useId().replace(/:/g, "");
const activePeriod = builtInPeriods?.find((period) => period.id === periodId);
const chartData = activePeriod?.data ?? data;
const resolvedSeries = useMemo(() => resolveSeries(series), [series]);
const totals = useMemo(
() => resolvedSeries.map((item) => chartData.reduce((sum, datum) => sum + numberAt(datum, item.key), 0)),
[chartData, resolvedSeries],
);
const point = activeIndex == null ? null : chartData[activeIndex];
const headline = point
? variant === "overlap"
? numberAt(point, resolvedSeries[0]?.key ?? "")
: resolvedSeries.reduce((sum, item) => sum + numberAt(point, item.key), 0)
: activePeriod?.headline ?? restingHeadline ?? (variant === "overlap"
? totals[0] ?? 0
: totals.reduce((sum, value) => sum + value, 0));
const displayedHeadline = useCountUp(headline, 320);
const shownDelta = delta ?? activePeriod?.delta;
function changePeriod(id: string) {
setActiveIndex(null);
setPeriodId(id);
onRangeChange?.(id);
}
return (
<section
className={cx(
"flex w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
tiles ? "h-auto" : "h-[329px]",
className,
)}
{...props}
>
<header className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-body-medium text-text-secondary">{point?.label ?? title}</p>
<div className="flex min-h-9 items-center gap-2">
<p
key={`${periodId}-${activeIndex ?? "total"}-${headline}`}
className="animate-number-fade text-title-1-medium tabular-nums text-text-primary"
aria-live="polite"
>
{valueFormatter(displayedHeadline)}
</p>
{shownDelta !== undefined && (
<Chip color={formatDelta(shownDelta).startsWith("-") ? "rose" : "lime"}>
{formatDelta(shownDelta)}
</Chip>
)}
</div>
</div>
<ChartRangeControl
ranges={builtInPeriods}
value={periodId}
onValueChange={changePeriod}
label={range}
variant="compact"
/>
</header>
<div
className={cx("min-h-0 w-full", tiles ? "h-[196px] shrink-0" : "flex-1")}
role="img"
aria-label={`${title} area chart, ${valueFormatter(headline)} total`}
onMouseLeave={() => setActiveIndex(null)}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 4, right: 6, bottom: 0, left: 0 }}
stackOffset={variant === "percent" ? "expand" : undefined}
onMouseMove={(state) => {
const index = Number(state?.activeTooltipIndex);
setActiveIndex(Number.isInteger(index) && index >= 0 ? index : null);
}}
onMouseLeave={() => setActiveIndex(null)}
>
<defs>
{resolvedSeries.map((item) => (
<linearGradient key={item.key} id={`${gradientPrefix}-${item.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={item.color} stopOpacity={variant === "stacked" ? 0.55 : 0.4} />
<stop offset="100%" stopColor={item.color} stopOpacity={0.04} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} stroke="var(--color-chart-track)" strokeDasharray="4 4" />
<XAxis
dataKey="label"
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
tickMargin={12}
tick={{ fill: "var(--color-text-tertiary)", fontSize: 13 }}
/>
<YAxis
width={44}
axisLine={false}
tickLine={false}
tickCount={variant === "percent" ? undefined : 4}
domain={variant === "percent" ? [0, 1] : undefined}
ticks={variant === "percent" ? [0, 0.25, 0.5, 0.75, 1] : undefined}
tickFormatter={variant === "percent" ? (value) => `${Math.round(Number(value) * 100)}%` : compact}
tick={{ fill: "var(--color-text-tertiary)", fontSize: 12 }}
/>
<Tooltip
content={() => null}
cursor={{ stroke: "var(--color-chart-cursor)", strokeWidth: 1, strokeDasharray: "4 4" }}
/>
{resolvedSeries.map((item) => (
<Area
key={item.key}
type={shape === "curved" ? "monotone" : "linear"}
dataKey={item.key}
name={item.label}
stackId={variant === "overlap" ? undefined : "total"}
stroke={activeSeries === item.key ? item.activeColor : item.color}
strokeWidth={2}
fill={`url(#${gradientPrefix}-${item.key})`}
fillOpacity={activeSeries && activeSeries !== item.key ? 0.35 : 1}
strokeOpacity={activeSeries && activeSeries !== item.key ? 0.4 : 1}
activeDot={{ r: 4, fill: item.activeColor, stroke: "var(--color-background-secondary-default)", strokeWidth: 2 }}
isAnimationActive
animationDuration={450}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
{tiles ? (
<div className="-mx-2 -mb-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
{resolvedSeries.map((item, index) => {
const value = point ? numberAt(point, item.key) : totals[index];
const isLastOdd = resolvedSeries.length % 2 === 1 && index === resolvedSeries.length - 1;
return (
<div
key={item.key}
className={cx(
"flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 transition-opacity duration-200 sm:col-span-2",
isLastOdd && "col-span-2",
activeSeries && activeSeries !== item.key && "opacity-40",
)}
onMouseEnter={() => setActiveSeries(item.key)}
onMouseLeave={() => setActiveSeries(null)}
>
<span className="flex min-w-0 max-w-full items-center gap-1.5 text-body-regular text-text-secondary">
<span className="size-3 shrink-0 rounded-[4px]" style={{ backgroundColor: item.color }} aria-hidden />
<span className="truncate">{item.label}</span>
</span>
<span className="text-body-medium tabular-nums text-text-primary">{valueFormatter(value)}</span>
</div>
);
})}
</div>
) : (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
{resolvedSeries.map((item) => (
<span
key={item.key}
className={cx(
"flex items-center gap-1.5 text-body-2-medium text-text-secondary transition-opacity duration-200",
activeSeries && activeSeries !== item.key && "opacity-50",
)}
onMouseEnter={() => setActiveSeries(item.key)}
onMouseLeave={() => setActiveSeries(null)}
>
<span className="size-3 rounded-[4px]" style={{ backgroundColor: item.color }} aria-hidden />
{item.label}
</span>
))}
</div>
)}
</section>
);
}
export default AreaChartCard;"use client";
import { useId, useMemo, useState, type HTMLAttributes } from "react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
export type AreaChartVariant = "stacked" | "overlap" | "percent";
export type AreaChartShape = "curved" | "sharp";
export interface AreaChartDatum {
label: string;
[seriesKey: string]: string | number;
}
export interface AreaChartSeries {
key: string;
label: string;
/** Any CSS color. Defaults follow BoardCN's chart tone cycle. */
color?: string;
/** Hover/active color. A custom color is mixed with black when omitted. */
activeColor?: string;
}
export interface AreaChartRange {
id: string;
label: string;
data: AreaChartDatum[];
delta?: number | string;
headline?: number;
}
/** @deprecated Prefer AreaChartRange/ranges; retained as a source-compatible alias. */
export interface AreaChartPeriod extends Omit<AreaChartRange, "id"> {
id?: string;
}
export interface AreaChartCardProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
title?: string;
data: AreaChartDatum[];
series: AreaChartSeries[];
ranges?: AreaChartRange[];
/** @deprecated Alias for ranges. */
periods?: AreaChartPeriod[];
defaultRange?: string;
defaultPeriod?: string;
onRangeChange?: (id: string) => void;
/** Static range label. Supplying it without periods renders a calendar pill. */
range?: string;
headline?: number;
delta?: number | string;
variant?: AreaChartVariant;
shape?: AreaChartShape;
tiles?: boolean;
valueFormatter?: (value: number) => string;
}
const TONES = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const integerFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
function numberAt(datum: AreaChartDatum, key: string) {
const value = datum[key];
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function compact(value: number) {
if (Math.abs(value) < 1000) return `${Math.round(value)}`;
const abbreviated = value / 1000;
return `${Number.isInteger(abbreviated) ? abbreviated : abbreviated.toFixed(1)}K`;
}
function formatDelta(delta: number | string) {
if (typeof delta === "string") return delta;
const sign = delta > 0 ? "+" : "";
return `${sign}${(delta * 100).toFixed(1)}%`;
}
function resolveSeries(series: AreaChartSeries[]) {
return series.map((item, index) => {
const tone = TONES[index % TONES.length];
const color = item.color ?? `var(--color-chart-${tone})`;
return {
...item,
color,
activeColor:
item.activeColor ??
(item.color ? `color-mix(in srgb, ${item.color} 82%, black)` : `var(--color-chart-${tone}-active)`),
};
});
}
/**
* BoardCN-compatible multi-series area card. Hover information is reflected in
* the headline and tiles; the plot intentionally has no floating tooltip.
*/
export function AreaChartCard({
title = "Visitors",
data,
series,
ranges,
periods,
defaultRange,
defaultPeriod,
onRangeChange,
range = "This year",
headline: restingHeadline,
delta,
variant = "stacked",
shape = "curved",
tiles = false,
valueFormatter = (value) => integerFormatter.format(value),
className,
...props
}: AreaChartCardProps) {
const normalizedPeriods = periods?.map((period, index) => ({
...period,
id: period.id ?? `period-${index}`,
}));
const builtInPeriods = ranges ?? normalizedPeriods;
const initialPeriod =
builtInPeriods?.find((period) =>
period.id === (defaultRange ?? defaultPeriod) || period.label === (defaultRange ?? defaultPeriod),
)?.id ??
builtInPeriods?.[0]?.id;
const [periodId, setPeriodId] = useState(initialPeriod);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [activeSeries, setActiveSeries] = useState<string | null>(null);
const gradientPrefix = useId().replace(/:/g, "");
const activePeriod = builtInPeriods?.find((period) => period.id === periodId);
const chartData = activePeriod?.data ?? data;
const resolvedSeries = useMemo(() => resolveSeries(series), [series]);
const totals = useMemo(
() => resolvedSeries.map((item) => chartData.reduce((sum, datum) => sum + numberAt(datum, item.key), 0)),
[chartData, resolvedSeries],
);
const point = activeIndex == null ? null : chartData[activeIndex];
const headline = point
? variant === "overlap"
? numberAt(point, resolvedSeries[0]?.key ?? "")
: resolvedSeries.reduce((sum, item) => sum + numberAt(point, item.key), 0)
: activePeriod?.headline ?? restingHeadline ?? (variant === "overlap"
? totals[0] ?? 0
: totals.reduce((sum, value) => sum + value, 0));
const displayedHeadline = useCountUp(headline, 320);
const shownDelta = delta ?? activePeriod?.delta;
function changePeriod(id: string) {
setActiveIndex(null);
setPeriodId(id);
onRangeChange?.(id);
}
return (
<section
className={cx(
"flex w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
tiles ? "h-auto" : "h-[329px]",
className,
)}
{...props}
>
<header className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-body-medium text-text-secondary">{point?.label ?? title}</p>
<div className="flex min-h-9 items-center gap-2">
<p
key={`${periodId}-${activeIndex ?? "total"}-${headline}`}
className="animate-number-fade text-title-1-medium tabular-nums text-text-primary"
aria-live="polite"
>
{valueFormatter(displayedHeadline)}
</p>
{shownDelta !== undefined && (
<Chip color={formatDelta(shownDelta).startsWith("-") ? "rose" : "lime"}>
{formatDelta(shownDelta)}
</Chip>
)}
</div>
</div>
<ChartRangeControl
ranges={builtInPeriods}
value={periodId}
onValueChange={changePeriod}
label={range}
variant="compact"
/>
</header>
<div
className={cx("min-h-0 w-full", tiles ? "h-[196px] shrink-0" : "flex-1")}
role="img"
aria-label={`${title} area chart, ${valueFormatter(headline)} total`}
onMouseLeave={() => setActiveIndex(null)}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 4, right: 6, bottom: 0, left: 0 }}
stackOffset={variant === "percent" ? "expand" : undefined}
onMouseMove={(state) => {
const index = Number(state?.activeTooltipIndex);
setActiveIndex(Number.isInteger(index) && index >= 0 ? index : null);
}}
onMouseLeave={() => setActiveIndex(null)}
>
<defs>
{resolvedSeries.map((item) => (
<linearGradient key={item.key} id={`${gradientPrefix}-${item.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={item.color} stopOpacity={variant === "stacked" ? 0.55 : 0.4} />
<stop offset="100%" stopColor={item.color} stopOpacity={0.04} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} stroke="var(--color-chart-track)" strokeDasharray="4 4" />
<XAxis
dataKey="label"
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
tickMargin={12}
tick={{ fill: "var(--color-text-tertiary)", fontSize: 13 }}
/>
<YAxis
width={44}
axisLine={false}
tickLine={false}
tickCount={variant === "percent" ? undefined : 4}
domain={variant === "percent" ? [0, 1] : undefined}
ticks={variant === "percent" ? [0, 0.25, 0.5, 0.75, 1] : undefined}
tickFormatter={variant === "percent" ? (value) => `${Math.round(Number(value) * 100)}%` : compact}
tick={{ fill: "var(--color-text-tertiary)", fontSize: 12 }}
/>
<Tooltip
content={() => null}
cursor={{ stroke: "var(--color-chart-cursor)", strokeWidth: 1, strokeDasharray: "4 4" }}
/>
{resolvedSeries.map((item) => (
<Area
key={item.key}
type={shape === "curved" ? "monotone" : "linear"}
dataKey={item.key}
name={item.label}
stackId={variant === "overlap" ? undefined : "total"}
stroke={activeSeries === item.key ? item.activeColor : item.color}
strokeWidth={2}
fill={`url(#${gradientPrefix}-${item.key})`}
fillOpacity={activeSeries && activeSeries !== item.key ? 0.35 : 1}
strokeOpacity={activeSeries && activeSeries !== item.key ? 0.4 : 1}
activeDot={{ r: 4, fill: item.activeColor, stroke: "var(--color-background-secondary-default)", strokeWidth: 2 }}
isAnimationActive
animationDuration={450}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
{tiles ? (
<div className="-mx-2 -mb-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
{resolvedSeries.map((item, index) => {
const value = point ? numberAt(point, item.key) : totals[index];
const isLastOdd = resolvedSeries.length % 2 === 1 && index === resolvedSeries.length - 1;
return (
<div
key={item.key}
className={cx(
"flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 transition-opacity duration-200 sm:col-span-2",
isLastOdd && "col-span-2",
activeSeries && activeSeries !== item.key && "opacity-40",
)}
onMouseEnter={() => setActiveSeries(item.key)}
onMouseLeave={() => setActiveSeries(null)}
>
<span className="flex min-w-0 max-w-full items-center gap-1.5 text-body-regular text-text-secondary">
<span className="size-3 shrink-0 rounded-[4px]" style={{ backgroundColor: item.color }} aria-hidden />
<span className="truncate">{item.label}</span>
</span>
<span className="text-body-medium tabular-nums text-text-primary">{valueFormatter(value)}</span>
</div>
);
})}
</div>
) : (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
{resolvedSeries.map((item) => (
<span
key={item.key}
className={cx(
"flex items-center gap-1.5 text-body-2-medium text-text-secondary transition-opacity duration-200",
activeSeries && activeSeries !== item.key && "opacity-50",
)}
onMouseEnter={() => setActiveSeries(item.key)}
onMouseLeave={() => setActiveSeries(null)}
>
<span className="size-3 rounded-[4px]" style={{ backgroundColor: item.color }} aria-hidden />
{item.label}
</span>
))}
</div>
)}
</section>
);
}
export default AreaChartCard;Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
AreaChartCard
BoardCN-compatible multi-series area card. Hover information is reflected in the headline and tiles; the plot intentionally has no floating tooltip.
| Prop | Type | Default | Description |
|---|---|---|---|
| datarequired | AreaChartDatum[] | — | — |
| seriesrequired | AreaChartSeries[] | — | — |
| defaultPeriod | string | — | — |
| defaultRange | string | — | — |
| delta | string | number | — | — |
| headline | number | — | — |
| onRangeChange | (id: string) => void | — | — |
| periods | AreaChartPeriod[] | — | @deprecated Alias for ranges. |
| range | string | This year | Static range label. Supplying it without periods renders a calendar pill. |
| ranges | AreaChartRange[] | — | — |
| shape | "curved" | "sharp" | curved | — |
| tiles | boolean | false | — |
| title | string | Visitors | — |
| valueFormatter | (value: number) => string | (value) => integerFormatter.format(value) | — |
| variant | "stacked" | "overlap" | "percent" | stacked | — |