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.json
npx shadcn@latest add https://boardcn.dev/r/area-chart-card.json

npm 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.

components/charts/area-chart-card.tsx
"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.

PropTypeDefaultDescription
datarequiredAreaChartDatum[]
seriesrequiredAreaChartSeries[]
defaultPeriodstring
defaultRangestring
deltastring | number
headlinenumber
onRangeChange(id: string) => void
periodsAreaChartPeriod[]@deprecated Alias for ranges.
rangestringThis yearStatic range label. Supplying it without periods renders a calendar pill.
rangesAreaChartRange[]
shape"curved" | "sharp"curved
tilesbooleanfalse
titlestringVisitors
valueFormatter(value: number) => string(value) => integerFormatter.format(value)
variant"stacked" | "overlap" | "percent"stacked