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.

Revenue Chart Card 01

A revenue comparison chart with a changing headline.

Installation

Set up the @cobalt registry first, as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/revenue-chart-card-01

Pass monthly data to replace the sample figures.

Source

"use client";

import { cn } from "cn";
import * as React from "react";
import { Area, ComposedChart, Line, 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, CardHeader } from "@/components/ui/card";
import {
  ChartActiveDot,
  ChartContainer,
  ChartTooltip,
} from "@/components/ui/chart";
import { Reveal } from "@/components/ui/reveal";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";

export interface RevenuePoint {
  label: string;
  current: number;
  previous: number;
}
const months = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];
const fullMonths = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
];
export interface RevenuePeriod {
  label: string;
  data: RevenuePoint[];
}

/** The smallest 1, 2, 2.5 or 5 step (times a power of ten) that covers `raw`. */
function niceStep(raw: number) {
  const power = 10 ** Math.floor(Math.log10(raw));
  const unit = [1, 2, 2.5, 5, 10].find((n) => n * power >= raw) ?? 10;
  return unit * power;
}

const dataDefault: RevenuePoint[] = [
  { label: "Jan", current: 7200, previous: 6400 },
  { label: "Feb", current: 8100, previous: 6900 },
  { label: "Mar", current: 8800, previous: 7600 },
  { label: "Apr", current: 8500, previous: 7900 },
  { label: "May", current: 9700, previous: 8200 },
  { label: "Jun", current: 10_400, previous: 9000 },
  { label: "Jul", current: 11_200, previous: 9600 },
  { label: "Aug", current: 10_800, previous: 9900 },
  { label: "Sep", current: 11_900, previous: 10_100 },
  { label: "Oct", current: 12_500, previous: 10_500 },
  { label: "Nov", current: 12_100, previous: 10_300 },
  { label: "Dec", current: 13_300, previous: 11_000 },
];
const money = (n: number) =>
  n.toLocaleString("en-US", { maximumFractionDigits: 0 });
const dollars = (n: number) => `$${money(n)}`;

const ACTIVE_COLOR = "var(--revenue-chart-active, var(--chart-2))";
const ACTIVE_DOT = <ChartActiveDot color={ACTIVE_COLOR} />;
const CHART_MARGIN = { top: 4, right: 6, bottom: 0, left: 0 };
const Y_TICK = { fontSize: 12 };
const X_TICK = { fontSize: 13 };
const CURSOR = {
  stroke: "var(--border-strong, var(--border))",
  strokeWidth: 1,
  strokeDasharray: "4 4",
};
const renderNothing = () => null;
const formatTick = (v: number) =>
  v >= 1000
    ? `$${(v / 1000).toFixed(v % 1000 === 0 ? 0 : 1)}K`
    : `$${Math.round(v)}`;

/**
 * The plot is memoised on purpose: hovering re-renders the card (the headline
 * follows the hovered month), and re-rendering the chart with fresh props
 * would restart the draw.
 */
const RevenuePlot = React.memo(function RevenuePlot({
  data,
  compare,
  step,
  gradientId,
  reduced,
  onActiveIndexChange,
}: {
  data: RevenuePoint[];
  compare: boolean;
  step: number;
  gradientId: string;
  reduced: boolean;
  onActiveIndexChange: (index: number | null) => void;
}) {
  const ticks = React.useMemo(() => [0, 1, 2, 3].map((n) => n * step), [step]);
  const domain = React.useMemo<[number, number]>(() => [0, step * 3], [step]);
  return (
    <ChartContainer config={{}} className="aspect-auto min-h-0 w-full flex-1">
      <ComposedChart
        data={data}
        margin={CHART_MARGIN}
        onMouseMove={(state) => {
          const index = Number(state?.activeTooltipIndex);
          if (state?.isTooltipActive && Number.isFinite(index)) {
            onActiveIndexChange(index);
          }
        }}
        onMouseLeave={() => onActiveIndexChange(null)}
      >
        <defs>
          <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
            <stop
              offset="0%"
              stopColor="var(--revenue-chart-fill, var(--chart-2))"
              stopOpacity={0.3}
            />
            <stop
              offset="100%"
              stopColor="var(--revenue-chart-fill, var(--chart-2))"
              stopOpacity={0}
            />
          </linearGradient>
        </defs>
        <YAxis
          width={44}
          domain={domain}
          ticks={ticks}
          tickFormatter={formatTick}
          tickLine={false}
          axisLine={false}
          tick={Y_TICK}
        />
        <XAxis
          dataKey="label"
          tickLine={false}
          axisLine={false}
          tickMargin={12}
          interval="preserveStartEnd"
          tick={X_TICK}
        />
        <ChartTooltip content={renderNothing} cursor={CURSOR} />
        {compare && (
          <Line
            type="monotone"
            dataKey="previous"
            stroke="var(--foreground-disabled, var(--border))"
            strokeWidth={2}
            strokeDasharray="5 5"
            dot={false}
            activeDot={false}
            isAnimationActive={!reduced}
            animationDuration={450}
          />
        )}
        <Area
          type="monotone"
          dataKey="current"
          stroke="none"
          fill={`url(#${gradientId})`}
          isAnimationActive={!reduced}
          animationDuration={450}
        />
        <Line
          type="monotone"
          dataKey="current"
          stroke={ACTIVE_COLOR}
          strokeWidth={2.5}
          dot={false}
          activeDot={ACTIVE_DOT}
          isAnimationActive={!reduced}
          animationDuration={450}
        />
      </ComposedChart>
    </ChartContainer>
  );
});

export function RevenueChartCard({
  data: dataProp = dataDefault,
  periods,
  compare = true,
  title = "Revenue",
  className,
}: {
  data?: RevenuePoint[];
  /** Weekly, monthly and yearly style tabs; each swaps the series. */
  periods?: RevenuePeriod[];
  /** Show the dashed previous series, its legend and the comparison line. */
  compare?: boolean;
  title?: string;
  className?: string;
}) {
  const [period, setPeriod] = React.useState(periods?.[0]?.label ?? "");
  const data =
    periods?.find((item) => item.label === period)?.data ??
    periods?.[0]?.data ??
    dataProp;
  const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
  const id = React.useId().replaceAll(":", "");
  const reduced = useReducedMotion();
  const point = activeIndex === null ? undefined : data[activeIndex];
  const total = data.reduce((sum, item) => sum + item.current, 0);
  const previous = data.reduce((sum, item) => sum + item.previous, 0);
  const value = point?.current ?? total;
  const comparison = point?.previous ?? previous;
  const change =
    comparison === 0
      ? null
      : Math.round(((value - comparison) / comparison) * 1000) / 10;
  const delta = change === null ? "New" : `${change > 0 ? "+" : ""}${change}%`;
  const valueRef = useCountUp<HTMLParagraphElement>(Math.round(value), {
    format: dollars,
  });
  const month = point ? months.indexOf(point.label) : -1;
  const step = React.useMemo(() => {
    const max = Math.max(
      1,
      ...data.map((d) =>
        compare ? Math.max(d.current, d.previous) : d.current
      )
    );
    return niceStep((max * 1.02) / 3);
  }, [data, compare]);
  return (
    <Reveal
      variant="card"
      render={
        <Card
          radius={16}
          variant="muted"
          // oxlint-disable-next-line shadcn/no-restyle -- reference: 24px between header and plot, 12px bottom padding
          className={cn("h-86 min-w-0 flex-1 gap-6 pb-3", className)}
        />
      }
    >
      <CardHeader className="flex w-full flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
        <div className="flex min-w-0 flex-col gap-0.5">
          <p className="w-full text-sm leading-5 font-medium text-muted-foreground">
            {point ? (month >= 0 ? fullMonths[month] : point.label) : title}
          </p>
          <div className="flex w-full items-center gap-2">
            <p
              ref={valueRef}
              className="text-2xl leading-8.5 font-medium whitespace-nowrap tabular-nums"
            >
              {dollars(Math.round(value))}
            </p>
            <Badge
              variant="label"
              tone={
                change === null || change === 0
                  ? "neutral"
                  : change > 0
                    ? "lime"
                    : "rose"
              }
            >
              {delta}
            </Badge>
          </div>
          {compare && (
            // oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: comparison line is 13px
            <p className="text-[13px] leading-4.5 font-medium text-muted-foreground tabular-nums">
              ${money(comparison)} {point ? "a year earlier" : "last year"}
            </p>
          )}
        </div>
        {periods && (
          <Tabs
            value={period}
            onValueChange={(next) => {
              if (next) {
                setPeriod(next);
                setActiveIndex(null);
              }
            }}
          >
            <TabsList aria-label="Time period" variant="segmented">
              {periods.map((item) => (
                <TabsTrigger key={item.label} value={item.label}>
                  {item.label}
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>
        )}
        {compare && !periods && (
          // oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: legend is 13px
          <dl className="flex flex-wrap items-center gap-4 text-[13px] leading-4.5 font-medium text-muted-foreground">
            <div className="flex items-center gap-1.5">
              <span
                className="size-2 rounded-full bg-(--revenue-chart-active,var(--chart-2))"
                aria-hidden="true"
              />
              <dt>This year</dt>
            </div>
            <div className="flex items-center gap-1.5">
              <span
                className="size-2 rounded-full bg-(--foreground-disabled,var(--border))"
                aria-hidden="true"
              />
              <dt>Last year</dt>
            </div>
          </dl>
        )}
      </CardHeader>
      <CardContent className="min-h-0 flex-1 gap-0">
        <RevenuePlot
          data={data}
          compare={compare}
          step={step}
          gradientId={id}
          reduced={reduced}
          onActiveIndexChange={setActiveIndex}
        />
      </CardContent>
    </Reveal>
  );
}