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.jsonnpx shadcn@latest add https://boardcn.dev/r/line-chart-card.jsonnpm 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.
"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.
| Prop | Type | Default | Description |
|---|---|---|---|
| periodsrequired | LineChartPeriods | — | — |
| className | string | — | — |
| defaultPeriod | string | — | — |
| shape | "curved" | "sharp" | curved | — |