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.

Line Chart Card 01

A period selector and summary above an area chart.

Sharp corners

Use shape="sharp" to connect points with straight segments.

Installation

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

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

Supply week, month, and year arrays containing label, value, and previous values. The summary compares the selected points with their previous values.

Source

"use client";

import { cn } from "cn";
import * as React from "react";
import { Area, AreaChart, 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,
  CardAction,
  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 LineChartPoint {
  label: string;
  value: number;
  previous: number;
}

type Period = "week" | "month" | "year";

const ACTIVE_DOT = <ChartActiveDot />;
const CHART_MARGIN = { top: 4, right: 8, bottom: 0, left: 0 };
const TICK = { fontSize: 12 };
const CURSOR = {
  stroke: "var(--border-strong, var(--border))",
  strokeWidth: 1,
  strokeDasharray: "4 4",
};
const renderNothing = () => null;
const formatTick = (value: number) =>
  value >= 1000 ? `${Number((value / 1000).toFixed(1))}k` : `${value}`;

/**
 * The plot is memoised on purpose: hovering re-renders the card (the headline
 * follows the hovered point), and re-rendering the chart with it would keep
 * restarting Recharts' draw and morph animation.
 */
const LineChartPlot = React.memo(function LineChartPlot({
  points,
  shape,
  gradientId,
  step,
  reduced,
  onActiveIndexChange,
}: {
  points: LineChartPoint[];
  shape: "curved" | "sharp";
  gradientId: string;
  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">
      <AreaChart
        data={points}
        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(--chart-1)" stopOpacity={0.35} />
            <stop offset="100%" stopColor="var(--chart-1)" stopOpacity={0} />
          </linearGradient>
        </defs>
        <YAxis
          width={40}
          domain={domain}
          ticks={ticks}
          tickFormatter={formatTick}
          tickLine={false}
          axisLine={false}
          tick={TICK}
        />
        <XAxis
          dataKey="label"
          tickLine={false}
          axisLine={false}
          tickMargin={10}
          interval="preserveStartEnd"
          tick={TICK}
        />
        <ChartTooltip content={renderNothing} cursor={CURSOR} />
        <Area
          type={shape === "sharp" ? "linear" : "monotone"}
          dataKey="value"
          stroke="var(--chart-1)"
          strokeWidth={2}
          fill={`url(#${gradientId})`}
          dot={false}
          activeDot={ACTIVE_DOT}
          isAnimationActive={!reduced}
          animationDuration={450}
        />
      </AreaChart>
    </ChartContainer>
  );
});

export function LineChartCard({
  data,
  title = "Visitors",
  shape = "curved",
  className,
}: {
  data: Record<Period, LineChartPoint[]>;
  title?: string;
  shape?: "curved" | "sharp";
  className?: string;
}) {
  const [period, setPeriod] = React.useState<Period>("week");
  const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
  const reduced = useReducedMotion();
  const id = React.useId().replace(/:/g, "");
  const points = data[period];
  const point = activeIndex === null ? undefined : points[activeIndex];
  const total =
    point?.value ?? points.reduce((sum, item) => sum + item.value, 0);
  const totalRef = useCountUp<HTMLParagraphElement>(total);
  const previous =
    point?.previous ?? points.reduce((sum, item) => sum + item.previous, 0);
  const change = previous
    ? Math.round(((total - previous) / previous) * 1000) / 10
    : null;
  const step = React.useMemo(() => {
    const max = Math.max(0, ...points.map((item) => item.value));
    const rawStep = (max * 1.05) / 3 || 1;
    const magnitude = 10 ** Math.floor(Math.log10(rawStep));
    return (
      ([1, 2, 2.5, 5, 10].find((n) => n * magnitude >= rawStep) ?? 10) *
      magnitude
    );
  }, [points]);

  return (
    <Reveal variant="card" className="w-full min-w-0">
      <Card
        variant="muted"
        className={cn("h-[320px] min-w-0 w-full", className)}
        aria-label={title}
      >
        <CardHeader>
          <div className="flex flex-col gap-0.5">
            <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 text-foreground tabular-nums"
              >
                {total.toLocaleString("en-US")}
              </p>
              {!point && (
                <Badge
                  variant="caption"
                  tone={
                    change === null || change === 0
                      ? "neutral"
                      : change > 0
                        ? "lime"
                        : "rose"
                  }
                >
                  {change === null
                    ? "New"
                    : `${change > 0 ? "+" : ""}${change}%`}
                </Badge>
              )}
            </div>
          </div>
          <CardAction>
            <Tabs
              value={period}
              onValueChange={(value) => {
                if (value === "week" || value === "month" || value === "year") {
                  setPeriod(value);
                  setActiveIndex(null);
                }
              }}
            >
              <TabsList indicator>
                <TabsTrigger value="week">Weekly</TabsTrigger>
                <TabsTrigger value="month">Monthly</TabsTrigger>
                <TabsTrigger value="year">Yearly</TabsTrigger>
              </TabsList>
            </Tabs>
          </CardAction>
        </CardHeader>
        <CardContent className="min-h-0 flex-1 gap-0">
          <LineChartPlot
            points={points}
            shape={shape}
            gradientId={id}
            step={step}
            reduced={reduced}
            onActiveIndexChange={setActiveIndex}
          />
        </CardContent>
      </Card>
    </Reveal>
  );
}