For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Area Chart Card 01

A multi-series area chart with period selection and totals.

Usage

Supply rows with a label and a numeric value for each series. The card supports stacked, overlap, and percent variants, plus smooth or sharp lines. Set tiles to show the totals in separate tiles. Optional ranges provide a period selector; pass popupClassName when the popup needs a scoped theme.

Source

"use client";

import { RiCalendarLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import {
  Area,
  AreaChart,
  CartesianGrid,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts";

import { useCountUp } from "@/components/hooks/use-count-up";
import { useReducedMotion } from "@/components/hooks/use-reduced-motion";
import { Badge } from "@/components/ui/badge";
import {
  Card,
  CardContent,
  CardAction,
  CardHeader,
} from "@/components/ui/card";
import { ChartActiveDot, ChartContainer } from "@/components/ui/chart";
import { Reveal } from "@/components/ui/reveal";
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

export interface AreaChartPoint {
  label: string;
  [key: string]: string | number;
}
export interface AreaChartSeries {
  key: string;
  label: string;
  color?: string;
}
export interface AreaChartRange {
  id: string;
  label: string;
  data: AreaChartPoint[];
  delta?: number;
}

const format = (value: number) =>
  value.toLocaleString("en-US", { maximumFractionDigits: 0 });

const CHART_MARGIN = { top: 4, right: 4, left: 0, bottom: 0 };
const TICK = { fontSize: 11 };
const CURSOR = {
  stroke: "var(--border-strong, var(--border))",
  strokeWidth: 1,
  strokeDasharray: "4 4",
};
const PERCENT_DOMAIN: [number, number] = [0, 100];
const AUTO_DOMAIN: [number, string] = [0, "auto"];
const renderNothing = () => null;

/** A number that rolls to its value without re-rendering anything around it. */
function CountText({ value }: { value: number }) {
  const ref = useCountUp<HTMLSpanElement>(value, { format });
  return <span ref={ref}>{format(value)}</span>;
}

/**
 * The plot is memoised on purpose: hovering re-renders the card (the headline
 * follows the hovered point), and re-rendering the chart with fresh props would
 * restart the draw.
 */
const AreaPlot = React.memo(function AreaPlot({
  chartData,
  series,
  colors,
  variant,
  shape,
  gradientId,
  reduced,
  onActiveIndexChange,
}: {
  chartData: AreaChartPoint[];
  series: AreaChartSeries[];
  colors: string[];
  variant: "stacked" | "overlap" | "percent";
  shape: "smooth" | "sharp";
  gradientId: string;
  reduced: boolean;
  onActiveIndexChange: (index: number | null) => void;
}) {
  const activeDots = React.useMemo(
    () => colors.map((color) => <ChartActiveDot key={color} color={color} />),
    [colors]
  );
  const formatTick = React.useCallback(
    (value: number) =>
      variant === "percent"
        ? `${value}%`
        : value >= 1000
          ? `${Number((value / 1000).toFixed(1))}K`
          : String(value),
    [variant]
  );
  return (
    <ChartContainer config={{}} className="aspect-auto h-[165px] w-full">
      <AreaChart
        data={chartData}
        margin={CHART_MARGIN}
        onMouseMove={(state) => {
          const index = Number(state?.activeTooltipIndex);
          if (state?.isTooltipActive && Number.isFinite(index)) {
            onActiveIndexChange(index);
          }
        }}
        onMouseLeave={() => onActiveIndexChange(null)}
      >
        <defs>
          {series.map((item, index) => (
            <linearGradient
              key={item.key}
              id={`${gradientId}-${index}`}
              x1="0"
              y1="0"
              x2="0"
              y2="1"
            >
              <stop offset="0%" stopColor={colors[index]} stopOpacity={0.35} />
              <stop
                offset="100%"
                stopColor={colors[index]}
                stopOpacity={0.02}
              />
            </linearGradient>
          ))}
        </defs>
        <CartesianGrid
          vertical={false}
          stroke="var(--border)"
          strokeDasharray="2 4"
        />
        <XAxis
          dataKey="label"
          tickLine={false}
          axisLine={false}
          tickMargin={8}
          interval="preserveStartEnd"
          tick={TICK}
        />
        <YAxis
          width={40}
          tickLine={false}
          axisLine={false}
          tick={TICK}
          domain={variant === "percent" ? PERCENT_DOMAIN : AUTO_DOMAIN}
          tickFormatter={formatTick}
        />
        <Tooltip content={renderNothing} cursor={CURSOR} />
        {series.map((item, index) => (
          <Area
            key={item.key}
            dataKey={item.key}
            type={shape === "sharp" ? "linear" : "monotone"}
            stackId={variant === "overlap" ? undefined : "area"}
            stroke={colors[index]}
            strokeWidth={2}
            fill={`url(#${gradientId}-${index})`}
            activeDot={activeDots[index]}
            isAnimationActive={!reduced}
            animationDuration={450}
          />
        ))}
      </AreaChart>
    </ChartContainer>
  );
});

export function AreaChartCard({
  title = "Activity",
  data,
  series,
  ranges,
  delta,
  variant = "stacked",
  shape = "smooth",
  tiles = false,
  popupClassName,
  className,
}: {
  title?: string;
  data: AreaChartPoint[];
  series: AreaChartSeries[];
  ranges?: AreaChartRange[];
  delta?: number;
  variant?: "stacked" | "overlap" | "percent";
  shape?: "smooth" | "sharp";
  tiles?: boolean;
  popupClassName?: string;
  className?: string;
}) {
  const [rangeId, setRangeId] = React.useState(ranges?.[0]?.id);
  const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
  const reduced = useReducedMotion();
  const id = React.useId().replaceAll(":", "");
  const range = ranges?.find((item) => item.id === rangeId);
  const rows = range?.data ?? data;
  const change = range?.delta ?? delta;
  const totals = series.map((item) =>
    rows.reduce((sum, row) => sum + (Number(row[item.key]) || 0), 0)
  );
  const point = activeIndex === null ? undefined : rows[activeIndex];
  const values = point
    ? series.map((item) => Number(point[item.key]) || 0)
    : totals;
  const total = values.reduce((sum, value) => sum + value, 0);
  const totalRef = useCountUp<HTMLParagraphElement>(total, { format });
  const chartData = React.useMemo(
    () =>
      variant === "percent"
        ? rows.map((row) => {
            const sum = series.reduce(
              (value, item) => value + (Number(row[item.key]) || 0),
              0
            );
            return {
              ...row,
              ...Object.fromEntries(
                series.map((item) => [
                  item.key,
                  sum ? ((Number(row[item.key]) || 0) / sum) * 100 : 0,
                ])
              ),
            };
          })
        : rows,
    [rows, series, variant]
  );
  const colors = React.useMemo(
    () =>
      series.map(
        (item, index) => item.color ?? `var(--chart-${(index % 5) + 1})`
      ),
    [series]
  );

  return (
    <Reveal
      variant="card"
      render={<Card variant="muted" className={cn("min-w-0", className)} />}
    >
      <CardHeader className="min-w-0">
        <div className="min-w-0">
          <p className="text-sm leading-5 font-medium text-muted-foreground">
            {point?.label ?? title}
          </p>
          <div className="flex items-center gap-2">
            <p
              ref={totalRef}
              className="text-2xl leading-8 font-medium tabular-nums"
            >
              {format(total)}
            </p>
            {!point && change !== undefined && (
              <Badge variant="label" tone={change >= 0 ? "lime" : "rose"}>
                {change > 0 ? "+" : ""}
                {(change * 100).toFixed(1)}%
              </Badge>
            )}
          </div>
        </div>
        <CardAction>
          {ranges && ranges.length > 0 && (
            <Select
              items={ranges.map((item) => ({
                value: item.id,
                label: item.label,
              }))}
              value={rangeId}
              onValueChange={(value) => {
                if (value !== null) {
                  setRangeId(value);
                  setActiveIndex(null);
                }
              }}
            >
              <SelectTrigger
                size="sm"
                // oxlint-disable-next-line shadcn/no-restyle -- reference: period picker keeps 14px text at 32px height (measured 121px wide)
                className="data-[size=sm]:text-sm data-[size=sm]:leading-5"
                aria-label="Time period"
              >
                <RiCalendarLine className="size-4 text-muted-foreground" />
                <SelectValue />
              </SelectTrigger>
              <SelectContent
                className={popupClassName}
                align="end"
                alignItemWithTrigger={false}
              >
                <SelectGroup>
                  {ranges.map((item) => (
                    <SelectItem key={item.id} value={item.id}>
                      {item.label}
                    </SelectItem>
                  ))}
                </SelectGroup>
              </SelectContent>
            </Select>
          )}
        </CardAction>
      </CardHeader>
      <CardContent>
        <AreaPlot
          chartData={chartData}
          series={series}
          colors={colors}
          variant={variant}
          shape={shape}
          gradientId={id}
          reduced={reduced}
          onActiveIndexChange={setActiveIndex}
        />
        <div
          className={cn(
            "flex flex-wrap justify-center gap-3",
            tiles &&
              "grid grid-cols-[repeat(auto-fit,minmax(min(100%,7rem),1fr))] gap-2"
          )}
        >
          {series.map((item, index) => (
            <div
              key={item.key}
              className={cn(
                "flex min-w-0 items-center gap-1.5 text-xs",
                tiles &&
                  "min-w-0 flex-col items-start gap-0 rounded-[8px] bg-background p-2"
              )}
            >
              <div className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
                <span
                  aria-hidden
                  className="size-2.5 shrink-0 rounded-[3px] bg-(--series-color)"
                  style={
                    { "--series-color": colors[index] } as React.CSSProperties
                  }
                />
                <span className="break-words">{item.label}</span>
              </div>
              <span className="font-medium tabular-nums">
                <CountText value={values[index] ?? 0} />
              </span>
            </div>
          ))}
        </div>
      </CardContent>
    </Reveal>
  );
}