Line Chart

Interactive line chart card.

Line

An interactive line chart.

Revenue

$18,240

+9.4%
function LineChartDemo() {
  return <LineChartCard periods={LINE_CHART_PERIODS} />;
}
function LineChartDemo() {
  return <LineChartCard periods={LINE_CHART_PERIODS} />;
}

Installation

npx shadcn@latest add https://boardcn.dev/r/line-chart-card.json
npx shadcn@latest add https://boardcn.dev/r/line-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/line-chart-card.tsx
"use client";

import { useId, useState } from "react";
import {
  Area,
  ComposedChart,
  Line,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts";
import { Chip, type ChipProps } from "@/components/base/badges/chip";
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/base/segmented-control/segmented-control";
import { useCountUp, usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

const MONTH_NAMES: Record<string, string> = {
  Jan: "January",
  Feb: "February",
  Mar: "March",
  Apr: "April",
  May: "May",
  Jun: "June",
  Jul: "July",
  Aug: "August",
  Sep: "September",
  Oct: "October",
  Nov: "November",
  Dec: "December",
};

export type LineChartDatum = {
  label: string;
  value: number;
};

export type LineChartPeriod = {
  id: string;
  label: string;
  total: number;
  delta: string;
  deltaColor: ChipProps["color"];
  data: readonly LineChartDatum[];
};

export type LineChartPeriods = Record<string, LineChartPeriod>;

export type LineChartShape = "curved" | "sharp";

export interface LineChartCardProps {
  periods: LineChartPeriods;
  defaultPeriod?: string;
  shape?: LineChartShape;
  className?: string;
}

const formatYAxisTick = (value: number) =>
  value === 0 ? "$0" : `$${Math.round(value / 1_000)}K`;

function ActiveDot({
  cx: dotX,
  cy: dotY,
  reducedMotion,
}: {
  cx?: number;
  cy?: number;
  reducedMotion: boolean;
}) {
  if (dotX == null || dotY == null) return null;

  return (
    <g>
      <circle
        cx={dotX}
        cy={dotY}
        r={5}
        fill="var(--color-chart-2-active)"
        opacity={0.3}
      >
        {!reducedMotion && (
          <>
            <animate attributeName="r" values="5;13" dur="1.4s" repeatCount="indefinite" />
            <animate
              attributeName="opacity"
              values="0.35;0"
              dur="1.4s"
              repeatCount="indefinite"
            />
          </>
        )}
      </circle>
      <circle
        cx={dotX}
        cy={dotY}
        r={5}
        fill="var(--color-chart-2-active)"
        stroke="var(--color-background-secondary-default)"
        strokeWidth={3}
      />
    </g>
  );
}

/**
 * Revenue line chart with a soft area wash and header-driven point details.
 * The Recharts tooltip panel is intentionally empty: pointer feedback appears
 * in the headline and active dot instead.
 */
export function LineChartCard({
  periods,
  defaultPeriod,
  shape = "curved",
  className,
}: LineChartCardProps) {
  const periodIds = Object.keys(periods);
  const initialPeriod =
    (defaultPeriod && periods[defaultPeriod] ? defaultPeriod : undefined) ?? periodIds[0];
  const [period, setPeriod] = useState(initialPeriod);
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const reducedMotion = usePrefersReducedMotion();
  const gradientId = useId();
  const currentPeriod = periods[period] ?? periods[periodIds[0]];
  const hasActivePoint =
    activeIndex !== null && activeIndex >= 0 && activeIndex < currentPeriod.data.length;
  const headlineValue = hasActivePoint
    ? currentPeriod.data[activeIndex].value
    : currentPeriod.total;
  const activeLabel = hasActivePoint ? currentPeriod.data[activeIndex].label : null;
  const headlineLabel = activeLabel
    ? (MONTH_NAMES[activeLabel] ?? activeLabel)
    : "Revenue";
  const displayedValue = useCountUp(headlineValue);
  const maximumValue = Math.max(...currentPeriod.data.map((point) => point.value));
  const curveType = shape === "sharp" ? "linear" : "monotone";

  return (
    <section
      className={cx(
        "flex h-[329px] min-w-0 flex-1 flex-col gap-6 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        className,
      )}
    >
      <div className="flex w-full flex-col gap-3 sm:flex-row sm:items-start sm:gap-0.5">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <p className="w-full text-body-medium text-text-secondary">{headlineLabel}</p>
          <div className="flex w-full items-center gap-2">
            <p
              key={`${period}:${activeIndex}`}
              className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums motion-reduce:animate-none"
            >
              ${displayedValue.toLocaleString()}
            </p>
            <Chip
              variant="bold"
              color={currentPeriod.deltaColor}
              className={hasActivePoint ? "invisible" : undefined}
            >
              {currentPeriod.delta}
            </Chip>
          </div>
        </div>

        <SegmentedControl
          selectedKeys={[period]}
          onSelectionChange={(keys) => {
            const nextPeriod = [...keys][0];
            if (nextPeriod && periods[String(nextPeriod)]) {
              setPeriod(String(nextPeriod));
              setActiveIndex(null);
            }
          }}
          aria-label="Revenue period"
          className="p-0 sm:p-1"
        >
          {periodIds.map((id) => (
            <SegmentedControlItem key={id} id={id}>
              {periods[id].label}
            </SegmentedControlItem>
          ))}
        </SegmentedControl>
      </div>

      <div className="min-h-0 w-full flex-1">
        <ResponsiveContainer width="100%" height="100%">
          <ComposedChart
            data={[...currentPeriod.data]}
            margin={{ top: 4, right: 6, bottom: 0, left: 0 }}
            onMouseMove={(state) => {
              const nextIndex = Number(state?.activeTooltipIndex);
              if (state?.isTooltipActive && Number.isFinite(nextIndex)) {
                setActiveIndex(nextIndex);
              }
            }}
            onMouseLeave={() => setActiveIndex(null)}
          >
            <defs>
              <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stopColor="var(--color-chart-2)" stopOpacity={0.35} />
                <stop offset="100%" stopColor="var(--color-chart-2)" stopOpacity={0} />
              </linearGradient>
            </defs>
            <YAxis
              width={44}
              domain={[0, maximumValue * 1.1]}
              tickCount={4}
              tickFormatter={formatYAxisTick}
              tickLine={false}
              axisLine={false}
              tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
            />
            <XAxis
              dataKey="label"
              tickLine={false}
              axisLine={false}
              tickMargin={12}
              interval="preserveStartEnd"
              tick={{ fontSize: 13, fill: "var(--color-text-tertiary)" }}
            />
            <Tooltip
              content={() => null}
              cursor={{
                stroke: "var(--color-chart-cursor)",
                strokeWidth: 1,
                strokeDasharray: "4 4",
              }}
            />
            <Area
              type={curveType}
              dataKey="value"
              stroke="none"
              fill={`url(#${gradientId})`}
              isAnimationActive={!reducedMotion}
              animationDuration={450}
            />
            <Line
              type={curveType}
              dataKey="value"
              stroke="var(--color-chart-2-active)"
              strokeWidth={2.5}
              dot={false}
              activeDot={<ActiveDot reducedMotion={reducedMotion} />}
              isAnimationActive={!reducedMotion}
              animationDuration={450}
            />
          </ComposedChart>
        </ResponsiveContainer>
      </div>
    </section>
  );
}
"use client";

import { useId, useState } from "react";
import {
  Area,
  ComposedChart,
  Line,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts";
import { Chip, type ChipProps } from "@/components/base/badges/chip";
import {
  SegmentedControl,
  SegmentedControlItem,
} from "@/components/base/segmented-control/segmented-control";
import { useCountUp, usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

const MONTH_NAMES: Record<string, string> = {
  Jan: "January",
  Feb: "February",
  Mar: "March",
  Apr: "April",
  May: "May",
  Jun: "June",
  Jul: "July",
  Aug: "August",
  Sep: "September",
  Oct: "October",
  Nov: "November",
  Dec: "December",
};

export type LineChartDatum = {
  label: string;
  value: number;
};

export type LineChartPeriod = {
  id: string;
  label: string;
  total: number;
  delta: string;
  deltaColor: ChipProps["color"];
  data: readonly LineChartDatum[];
};

export type LineChartPeriods = Record<string, LineChartPeriod>;

export type LineChartShape = "curved" | "sharp";

export interface LineChartCardProps {
  periods: LineChartPeriods;
  defaultPeriod?: string;
  shape?: LineChartShape;
  className?: string;
}

const formatYAxisTick = (value: number) =>
  value === 0 ? "$0" : `$${Math.round(value / 1_000)}K`;

function ActiveDot({
  cx: dotX,
  cy: dotY,
  reducedMotion,
}: {
  cx?: number;
  cy?: number;
  reducedMotion: boolean;
}) {
  if (dotX == null || dotY == null) return null;

  return (
    <g>
      <circle
        cx={dotX}
        cy={dotY}
        r={5}
        fill="var(--color-chart-2-active)"
        opacity={0.3}
      >
        {!reducedMotion && (
          <>
            <animate attributeName="r" values="5;13" dur="1.4s" repeatCount="indefinite" />
            <animate
              attributeName="opacity"
              values="0.35;0"
              dur="1.4s"
              repeatCount="indefinite"
            />
          </>
        )}
      </circle>
      <circle
        cx={dotX}
        cy={dotY}
        r={5}
        fill="var(--color-chart-2-active)"
        stroke="var(--color-background-secondary-default)"
        strokeWidth={3}
      />
    </g>
  );
}

/**
 * Revenue line chart with a soft area wash and header-driven point details.
 * The Recharts tooltip panel is intentionally empty: pointer feedback appears
 * in the headline and active dot instead.
 */
export function LineChartCard({
  periods,
  defaultPeriod,
  shape = "curved",
  className,
}: LineChartCardProps) {
  const periodIds = Object.keys(periods);
  const initialPeriod =
    (defaultPeriod && periods[defaultPeriod] ? defaultPeriod : undefined) ?? periodIds[0];
  const [period, setPeriod] = useState(initialPeriod);
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const reducedMotion = usePrefersReducedMotion();
  const gradientId = useId();
  const currentPeriod = periods[period] ?? periods[periodIds[0]];
  const hasActivePoint =
    activeIndex !== null && activeIndex >= 0 && activeIndex < currentPeriod.data.length;
  const headlineValue = hasActivePoint
    ? currentPeriod.data[activeIndex].value
    : currentPeriod.total;
  const activeLabel = hasActivePoint ? currentPeriod.data[activeIndex].label : null;
  const headlineLabel = activeLabel
    ? (MONTH_NAMES[activeLabel] ?? activeLabel)
    : "Revenue";
  const displayedValue = useCountUp(headlineValue);
  const maximumValue = Math.max(...currentPeriod.data.map((point) => point.value));
  const curveType = shape === "sharp" ? "linear" : "monotone";

  return (
    <section
      className={cx(
        "flex h-[329px] min-w-0 flex-1 flex-col gap-6 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        className,
      )}
    >
      <div className="flex w-full flex-col gap-3 sm:flex-row sm:items-start sm:gap-0.5">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <p className="w-full text-body-medium text-text-secondary">{headlineLabel}</p>
          <div className="flex w-full items-center gap-2">
            <p
              key={`${period}:${activeIndex}`}
              className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums motion-reduce:animate-none"
            >
              ${displayedValue.toLocaleString()}
            </p>
            <Chip
              variant="bold"
              color={currentPeriod.deltaColor}
              className={hasActivePoint ? "invisible" : undefined}
            >
              {currentPeriod.delta}
            </Chip>
          </div>
        </div>

        <SegmentedControl
          selectedKeys={[period]}
          onSelectionChange={(keys) => {
            const nextPeriod = [...keys][0];
            if (nextPeriod && periods[String(nextPeriod)]) {
              setPeriod(String(nextPeriod));
              setActiveIndex(null);
            }
          }}
          aria-label="Revenue period"
          className="p-0 sm:p-1"
        >
          {periodIds.map((id) => (
            <SegmentedControlItem key={id} id={id}>
              {periods[id].label}
            </SegmentedControlItem>
          ))}
        </SegmentedControl>
      </div>

      <div className="min-h-0 w-full flex-1">
        <ResponsiveContainer width="100%" height="100%">
          <ComposedChart
            data={[...currentPeriod.data]}
            margin={{ top: 4, right: 6, bottom: 0, left: 0 }}
            onMouseMove={(state) => {
              const nextIndex = Number(state?.activeTooltipIndex);
              if (state?.isTooltipActive && Number.isFinite(nextIndex)) {
                setActiveIndex(nextIndex);
              }
            }}
            onMouseLeave={() => setActiveIndex(null)}
          >
            <defs>
              <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stopColor="var(--color-chart-2)" stopOpacity={0.35} />
                <stop offset="100%" stopColor="var(--color-chart-2)" stopOpacity={0} />
              </linearGradient>
            </defs>
            <YAxis
              width={44}
              domain={[0, maximumValue * 1.1]}
              tickCount={4}
              tickFormatter={formatYAxisTick}
              tickLine={false}
              axisLine={false}
              tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
            />
            <XAxis
              dataKey="label"
              tickLine={false}
              axisLine={false}
              tickMargin={12}
              interval="preserveStartEnd"
              tick={{ fontSize: 13, fill: "var(--color-text-tertiary)" }}
            />
            <Tooltip
              content={() => null}
              cursor={{
                stroke: "var(--color-chart-cursor)",
                strokeWidth: 1,
                strokeDasharray: "4 4",
              }}
            />
            <Area
              type={curveType}
              dataKey="value"
              stroke="none"
              fill={`url(#${gradientId})`}
              isAnimationActive={!reducedMotion}
              animationDuration={450}
            />
            <Line
              type={curveType}
              dataKey="value"
              stroke="var(--color-chart-2-active)"
              strokeWidth={2.5}
              dot={false}
              activeDot={<ActiveDot reducedMotion={reducedMotion} />}
              isAnimationActive={!reducedMotion}
              animationDuration={450}
            />
          </ComposedChart>
        </ResponsiveContainer>
      </div>
    </section>
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

LineChartCard

Revenue line chart with a soft area wash and header-driven point details. The Recharts tooltip panel is intentionally empty: pointer feedback appears in the headline and active dot instead.

PropTypeDefaultDescription
periodsrequiredLineChartPeriods
classNamestring
defaultPeriodstring
shape"curved" | "sharp"curved