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.

Orders Chart Card 01

A monthly comparison chart with an animated total.

Installation

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

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

Pass monthly data with label, current, and previous values to the card.

Source

"use client";

import { cn } from "cn";
import * as React from "react";
import { Bar, BarChart, 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, CardHeader } from "@/components/ui/card";
import { ChartContainer } from "@/components/ui/chart";
import { Reveal } from "@/components/ui/reveal";

export interface OrdersPoint {
  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",
];

const CHART_MARGIN = { top: 4, right: 6, bottom: 0, left: 0 };
const Y_TICK = { fontSize: 12 };
const X_TICK = { fontSize: 13 };
const BAR_RADIUS: [number, number, number, number] = [4, 4, 0, 0];
const CURSOR = { fill: "var(--surface-strong, var(--muted))", opacity: 0.5 };
const renderNothing = () => null;
const formatTick = (v: number) =>
  v >= 1000 ? `${(v / 1000).toFixed(v % 1000 === 0 ? 0 : 1)}k` : `${v}`;

/**
 * The plot is memoised on purpose: hovering re-renders the card (the headline
 * follows the hovered month), and a re-render of the chart with fresh props
 * would restart the bar growth.
 */
const OrdersPlot = React.memo(function OrdersPlot({
  data,
  step,
  reduced,
  onActiveIndexChange,
}: {
  data: OrdersPoint[];
  step: number;
  reduced: boolean;
  onActiveIndexChange: (index: number | null) => void;
}) {
  const ticks = React.useMemo(() => [0, step, step * 2, step * 3], [step]);
  const domain = React.useMemo<[number, number]>(() => [0, step * 3], [step]);
  return (
    <ChartContainer config={{}} className="aspect-auto min-h-0 w-full flex-1">
      <BarChart
        data={data}
        margin={CHART_MARGIN}
        barCategoryGap="28%"
        barGap={3}
        onMouseMove={(state) => {
          const index = Number(state?.activeTooltipIndex);
          if (state?.isTooltipActive && Number.isFinite(index)) {
            onActiveIndexChange(index);
          }
        }}
        onMouseLeave={() => onActiveIndexChange(null)}
      >
        <YAxis
          width={40}
          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}
        />
        <Tooltip content={renderNothing} cursor={CURSOR} />
        <Bar
          dataKey="previous"
          fill="var(--orders-chart-previous, var(--muted-foreground))"
          radius={BAR_RADIUS}
          isAnimationActive={!reduced}
          animationDuration={450}
        />
        <Bar
          dataKey="current"
          fill="var(--orders-chart-current, var(--chart-1))"
          radius={BAR_RADIUS}
          isAnimationActive={!reduced}
          animationDuration={450}
        />
      </BarChart>
    </ChartContainer>
  );
});

export function OrdersChartCard({
  data,
  title = "Orders",
  className,
}: {
  data: OrdersPoint[];
  title?: string;
  className?: string;
}) {
  const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
  const reduced = useReducedMotion();
  const point =
    activeIndex !== null && activeIndex >= 0 ? data[activeIndex] : undefined;
  const current = point?.current ?? data.reduce((sum, p) => sum + p.current, 0);
  const previous =
    point?.previous ?? data.reduce((sum, p) => sum + p.previous, 0);
  const change =
    previous === 0
      ? null
      : Math.round(((current - previous) / previous) * 1000) / 10;
  const delta = change === null ? "New" : `${change > 0 ? "+" : ""}${change}%`;
  const month = point ? months.indexOf(point.label) : -1;
  const totalRef = useCountUp<HTMLParagraphElement>(current);
  const step = React.useMemo(() => {
    const max = Math.max(
      0,
      ...data.map((p) => Math.max(p.current, p.previous))
    );
    // Round the axis to a tidy step so the top tick is never a long decimal.
    const rawStep = (max * 1.05) / 3 || 1;
    const magnitude = 10 ** Math.floor(Math.log10(rawStep));
    return (
      ([1, 2, 2.5, 3, 4, 5, 10].find((n) => n * magnitude >= rawStep) ?? 10) *
      magnitude
    );
  }, [data]);
  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={totalRef}
              className="text-2xl leading-8.5 font-medium whitespace-nowrap tabular-nums"
            >
              {current.toLocaleString("en-US")}
            </p>
            <Badge
              variant="label"
              tone={
                change === null || change === 0
                  ? "neutral"
                  : change > 0
                    ? "lime"
                    : "rose"
              }
            >
              {delta}
            </Badge>
          </div>
          {/* oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: year-earlier line is 13px */}
          <p className="text-[13px] leading-4.5 font-medium text-muted-foreground tabular-nums">
            {previous.toLocaleString("en-US")}{" "}
            {point ? "a year earlier" : "last year"}
          </p>
        </div>
        {/* 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-(--orders-chart-current,var(--chart-1))"
              aria-hidden
            />
            <dt>This year</dt>
          </div>
          <div className="flex items-center gap-1.5">
            <span
              className="size-2 rounded-full bg-(--orders-chart-previous,var(--muted-foreground))"
              aria-hidden
            />
            <dt>Last year</dt>
          </div>
        </dl>
      </CardHeader>
      <CardContent className="min-h-0 flex-1 gap-0">
        <OrdersPlot
          data={data}
          step={step}
          reduced={reduced}
          onActiveIndexChange={setActiveIndex}
        />
      </CardContent>
    </Reveal>
  );
}