Earnings Chart
Interactive earnings bar chart.
Earnings
An interactive bar chart.
Earned so far
$7,462
+14.8%function EarningsChartDemo() {
return <EarningsChartCard periods={EARNINGS_CHART_PERIODS} />;
}function EarningsChartDemo() {
return <EarningsChartCard periods={EARNINGS_CHART_PERIODS} />;
}Installation
npx shadcn@latest add https://boardcn.dev/r/earnings-chart-card.jsonnpx shadcn@latest add https://boardcn.dev/r/earnings-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 { useState } from "react";
import {
Bar,
BarChart,
Cell,
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 } 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 EarningsChartDatum = {
label: string;
value: number;
};
export type EarningsChartPeriod = {
id: string;
label: string;
total: number;
delta: string;
deltaColor: ChipProps["color"];
data: readonly EarningsChartDatum[];
};
export type EarningsChartPeriods = Record<string, EarningsChartPeriod>;
const Y_AXIS_TICKS = [0, 3_000, 5_000, 10_000];
const BAR_RADIUS: [number, number, number, number] = [10, 10, 10, 10];
const formatYAxisTick = (value: number) => (value === 0 ? "$0" : `$${value / 1_000}K`);
export interface EarningsChartCardProps {
periods: EarningsChartPeriods;
defaultPeriod?: string;
className?: string;
}
export function EarningsChartCard({
periods,
defaultPeriod,
className,
}: EarningsChartCardProps) {
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 currentPeriod = periods[period] ?? periods[periodIds[0]];
const hasActiveBar = activeIndex !== null && activeIndex < currentPeriod.data.length;
const headlineValue = hasActiveBar
? currentPeriod.data[activeIndex].value
: currentPeriod.total;
const activeLabel = hasActiveBar ? currentPeriod.data[activeIndex].label : null;
const headlineLabel = activeLabel
? (MONTH_NAMES[activeLabel] ?? activeLabel)
: "Earned so far";
const displayedValue = useCountUp(headlineValue);
return (
<section
className={cx(
"flex h-[329px] w-full min-w-0 flex-col gap-6 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3 xl:w-[673px] xl:shrink-0",
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"
>
${displayedValue.toLocaleString()}
</p>
<Chip
variant="bold"
color={currentPeriod.deltaColor}
className={hasActiveBar ? "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="Earnings 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%">
<BarChart
data={[...currentPeriod.data]}
margin={{ top: 4, right: 0, bottom: 0, left: 0 }}
barCategoryGap="18%"
onMouseMove={(state) => {
const nextIndex = Number(state?.activeTooltipIndex);
setActiveIndex(
state?.isTooltipActive && Number.isInteger(nextIndex) ? nextIndex : null,
);
}}
onMouseLeave={() => setActiveIndex(null)}
>
<YAxis
width={44}
domain={[0, 12_000]}
ticks={Y_AXIS_TICKS}
tickFormatter={formatYAxisTick}
tickLine={false}
axisLine={false}
tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
/>
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={12}
tick={{ fontSize: 13, fill: "var(--color-text-tertiary)" }}
/>
<Tooltip cursor={false} content={() => null} isAnimationActive={false} />
<Bar
dataKey="value"
fill="var(--color-chart-2)"
radius={BAR_RADIUS}
maxBarSize={40}
background={({ x = 0, y = 0, width = 0, height = 0, index }) => (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
rx={10}
ry={10}
fill="var(--color-chart-track)"
/>
<rect
className="transition-opacity duration-150 ease-out"
x={x - 3}
y={y - 3}
width={width + 6}
height={height + 6}
rx={13}
ry={13}
fill="none"
stroke="var(--color-chart-cursor)"
strokeWidth={2}
opacity={Number(index === activeIndex)}
/>
</g>
)}
activeBar={false}
isAnimationActive
animationDuration={450}
>
{currentPeriod.data.map((point, index) => (
<Cell
key={point.label}
fill={
index === activeIndex
? "var(--color-chart-2-active)"
: "var(--color-chart-2)"
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</section>
);
}"use client";
import { useState } from "react";
import {
Bar,
BarChart,
Cell,
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 } 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 EarningsChartDatum = {
label: string;
value: number;
};
export type EarningsChartPeriod = {
id: string;
label: string;
total: number;
delta: string;
deltaColor: ChipProps["color"];
data: readonly EarningsChartDatum[];
};
export type EarningsChartPeriods = Record<string, EarningsChartPeriod>;
const Y_AXIS_TICKS = [0, 3_000, 5_000, 10_000];
const BAR_RADIUS: [number, number, number, number] = [10, 10, 10, 10];
const formatYAxisTick = (value: number) => (value === 0 ? "$0" : `$${value / 1_000}K`);
export interface EarningsChartCardProps {
periods: EarningsChartPeriods;
defaultPeriod?: string;
className?: string;
}
export function EarningsChartCard({
periods,
defaultPeriod,
className,
}: EarningsChartCardProps) {
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 currentPeriod = periods[period] ?? periods[periodIds[0]];
const hasActiveBar = activeIndex !== null && activeIndex < currentPeriod.data.length;
const headlineValue = hasActiveBar
? currentPeriod.data[activeIndex].value
: currentPeriod.total;
const activeLabel = hasActiveBar ? currentPeriod.data[activeIndex].label : null;
const headlineLabel = activeLabel
? (MONTH_NAMES[activeLabel] ?? activeLabel)
: "Earned so far";
const displayedValue = useCountUp(headlineValue);
return (
<section
className={cx(
"flex h-[329px] w-full min-w-0 flex-col gap-6 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3 xl:w-[673px] xl:shrink-0",
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"
>
${displayedValue.toLocaleString()}
</p>
<Chip
variant="bold"
color={currentPeriod.deltaColor}
className={hasActiveBar ? "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="Earnings 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%">
<BarChart
data={[...currentPeriod.data]}
margin={{ top: 4, right: 0, bottom: 0, left: 0 }}
barCategoryGap="18%"
onMouseMove={(state) => {
const nextIndex = Number(state?.activeTooltipIndex);
setActiveIndex(
state?.isTooltipActive && Number.isInteger(nextIndex) ? nextIndex : null,
);
}}
onMouseLeave={() => setActiveIndex(null)}
>
<YAxis
width={44}
domain={[0, 12_000]}
ticks={Y_AXIS_TICKS}
tickFormatter={formatYAxisTick}
tickLine={false}
axisLine={false}
tick={{ fontSize: 12, fill: "var(--color-text-tertiary)" }}
/>
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={12}
tick={{ fontSize: 13, fill: "var(--color-text-tertiary)" }}
/>
<Tooltip cursor={false} content={() => null} isAnimationActive={false} />
<Bar
dataKey="value"
fill="var(--color-chart-2)"
radius={BAR_RADIUS}
maxBarSize={40}
background={({ x = 0, y = 0, width = 0, height = 0, index }) => (
<g>
<rect
x={x}
y={y}
width={width}
height={height}
rx={10}
ry={10}
fill="var(--color-chart-track)"
/>
<rect
className="transition-opacity duration-150 ease-out"
x={x - 3}
y={y - 3}
width={width + 6}
height={height + 6}
rx={13}
ry={13}
fill="none"
stroke="var(--color-chart-cursor)"
strokeWidth={2}
opacity={Number(index === activeIndex)}
/>
</g>
)}
activeBar={false}
isAnimationActive
animationDuration={450}
>
{currentPeriod.data.map((point, index) => (
<Cell
key={point.label}
fill={
index === activeIndex
? "var(--color-chart-2-active)"
: "var(--color-chart-2)"
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</section>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
EarningsChartCard
| Prop | Type | Default | Description |
|---|---|---|---|
| periodsrequired | EarningsChartPeriods | — | — |
| className | string | — | — |
| defaultPeriod | string | — | — |