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.

Earnings Chart Card 01

A revenue chart with selectable reporting periods.

Installation

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

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

Pass an array of periods with a label, total, percentage change, and chart points.

Source

"use client";

import { cn } from "cn";
import * as React from "react";

import { useCountUp } from "@/components/hooks/use-count-up";
import { Badge } from "@/components/ui/badge";
import { Reveal } from "@/components/ui/reveal";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";

export interface EarningsPoint {
  label: string;
  value: number;
}

export interface EarningsPeriod {
  label: string;
  total: number;
  change: number;
  points: EarningsPoint[];
}

const money = (value: number) =>
  new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    maximumFractionDigits: 0,
  }).format(value);

/** Arrow keys move focus between the bars. */
function moveFocus(event: React.KeyboardEvent<HTMLDivElement>) {
  const offset =
    event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
  if (!offset) {
    return;
  }
  const siblings = [
    ...(event.currentTarget.parentElement?.children ?? []),
  ] as HTMLElement[];
  const next =
    siblings[siblings.indexOf(event.currentTarget) + offset] ?? undefined;
  if (next) {
    event.preventDefault();
    next.focus();
  }
}

export function EarningsChartCard({
  periods,
  title = "Revenue collected",
  className,
}: {
  periods: EarningsPeriod[];
  title?: string;
  className?: string;
}) {
  const [selected, setSelected] = React.useState(periods[0]?.label ?? "");
  const [active, setActive] = React.useState<number | null>(null);
  const period = periods.find((item) => item.label === selected) ?? periods[0];
  const shown = period;

  const hovered = active === null ? undefined : shown?.points[active];
  const valueRef = useCountUp<HTMLParagraphElement>(
    hovered ? hovered.value : (period?.total ?? 0),
    { format: money }
  );

  if (!period || !shown) {
    return null;
  }
  const max = Math.max(1, ...shown.points.map((point) => point.value));
  const step = Math.max(1, Math.ceil(max / 4 / 1000) * 1000);
  const ceiling = step * 4;

  return (
    <Reveal variant="card" className="min-w-0">
      <section
        className={cn(
          "flex min-w-0 flex-col gap-6 rounded-[16px] bg-surface-subtle p-4",
          className
        )}
        aria-label={title}
      >
        <div className="flex flex-wrap items-start justify-between gap-3">
          <div>
            <p className="text-sm leading-5 font-medium text-muted-foreground">
              {hovered ? hovered.label : title}
            </p>
            <div className="flex items-center gap-2">
              <p
                ref={valueRef}
                className="text-2xl leading-8.5 font-medium text-foreground tabular-nums"
              >
                {money(hovered ? hovered.value : period.total)}
              </p>
              {!hovered && (
                <Badge
                  variant="label"
                  tone={period.change >= 0 ? "lime" : "rose"}
                >
                  {period.change > 0 ? "+" : ""}
                  {period.change}%
                </Badge>
              )}
            </div>
          </div>
          <Tabs
            value={period.label}
            onValueChange={(value) => {
              if (value) {
                setSelected(value);
                setActive(null);
              }
            }}
          >
            <TabsList aria-label="Time period" variant="segmented" indicator>
              {periods.map((item) => (
                <TabsTrigger key={item.label} value={item.label}>
                  {item.label}
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>
        </div>
        <div className="flex h-[214px] min-w-0 gap-3">
          <div
            className="flex h-[188px] w-9 shrink-0 flex-col justify-between pt-0.5 text-right text-xs text-muted-foreground/70"
            aria-hidden="true"
          >
            {[4, 3, 2, 1, 0].map((n) => (
              <span key={n}>
                {n === 0 ? "$0" : `$${((step * n) / 1000).toLocaleString()}K`}
              </span>
            ))}
          </div>
          <div
            className="flex min-w-0 flex-1 gap-2 sm:gap-4"
            role="group"
            aria-label={`${shown.label} ${title}`}
          >
            {shown.points.map((point, index) => (
              <div
                key={index}
                className="flex min-w-0 flex-1 flex-col items-center gap-3 outline-none"
                role="img"
                // Each bar is focusable so keyboard users can reach its tooltip.
                // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex
                tabIndex={0}
                aria-label={`${point.label} ${money(point.value)}`}
                onMouseEnter={() => setActive(index)}
                onMouseLeave={() => setActive(null)}
                onFocus={() => setActive(index)}
                onBlur={() => setActive(null)}
                onKeyDown={moveFocus}
              >
                <div
                  className={cn(
                    "relative h-[188px] w-full max-w-8 overflow-hidden rounded-[10px] bg-surface-strong ring-0 ring-(color:--border-strong,var(--border)) ring-offset-2 ring-offset-(--surface-subtle,var(--background)) transition-shadow duration-150 ease-out motion-reduce:transition-none",
                    active === index && "ring-2"
                  )}
                >
                  <div
                    className={cn(
                      "absolute inset-x-0 bottom-0 h-(--bar-height) rounded-[10px] earnings-chart-card-01-bar bg-(--revenue-chart-fill,var(--primary)) starting:h-0",
                      active === index &&
                        "bg-(--revenue-chart-active,var(--primary))"
                    )}
                    style={
                      {
                        "--bar-height": `${Math.max(0, point.value / ceiling) * 100}%`,
                      } as React.CSSProperties
                    }
                  />
                </div>
                <span
                  className="w-full truncate text-center text-xs leading-4.5 text-muted-foreground/70"
                  aria-hidden="true"
                >
                  <span className="sm:hidden">{point.label.charAt(0)}</span>
                  <span className="hidden sm:inline">{point.label}</span>
                </span>
              </div>
            ))}
          </div>
        </div>
      </section>
    </Reveal>
  );
}