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

PropTypeDefaultDescription
periodsrequiredEarningsChartPeriods
classNamestring
defaultPeriodstring