- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Revenue
$124,500
+15.9%$107,400 last year
- This year
- Last year
Installation
Set up the @cobalt registry first, as described in Installation, then add the block:
$ pnpm dlx shadcn@latest add @cobalt/revenue-chart-card-01
Pass monthly data to replace the sample figures.
Source
"use client";
import { cn } from "cn";
import * as React from "react";
import { Area, ComposedChart, Line, XAxis, YAxis } from "recharts";
import { useCountUp } from "@/components/hooks/use-count-up";
import { useReducedMotion } from "@/components/hooks/use-reduced-motion";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
ChartActiveDot,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart";
import { Reveal } from "@/components/ui/reveal";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export interface RevenuePoint {
label: string;
current: number;
previous: number;
}
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const fullMonths = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export interface RevenuePeriod {
label: string;
data: RevenuePoint[];
}
/** The smallest 1, 2, 2.5 or 5 step (times a power of ten) that covers `raw`. */
function niceStep(raw: number) {
const power = 10 ** Math.floor(Math.log10(raw));
const unit = [1, 2, 2.5, 5, 10].find((n) => n * power >= raw) ?? 10;
return unit * power;
}
const dataDefault: RevenuePoint[] = [
{ label: "Jan", current: 7200, previous: 6400 },
{ label: "Feb", current: 8100, previous: 6900 },
{ label: "Mar", current: 8800, previous: 7600 },
{ label: "Apr", current: 8500, previous: 7900 },
{ label: "May", current: 9700, previous: 8200 },
{ label: "Jun", current: 10_400, previous: 9000 },
{ label: "Jul", current: 11_200, previous: 9600 },
{ label: "Aug", current: 10_800, previous: 9900 },
{ label: "Sep", current: 11_900, previous: 10_100 },
{ label: "Oct", current: 12_500, previous: 10_500 },
{ label: "Nov", current: 12_100, previous: 10_300 },
{ label: "Dec", current: 13_300, previous: 11_000 },
];
const money = (n: number) =>
n.toLocaleString("en-US", { maximumFractionDigits: 0 });
const dollars = (n: number) => `$${money(n)}`;
const ACTIVE_COLOR = "var(--revenue-chart-active, var(--chart-2))";
const ACTIVE_DOT = <ChartActiveDot color={ACTIVE_COLOR} />;
const CHART_MARGIN = { top: 4, right: 6, bottom: 0, left: 0 };
const Y_TICK = { fontSize: 12 };
const X_TICK = { fontSize: 13 };
const CURSOR = {
stroke: "var(--border-strong, var(--border))",
strokeWidth: 1,
strokeDasharray: "4 4",
};
const renderNothing = () => null;
const formatTick = (v: number) =>
v >= 1000
? `$${(v / 1000).toFixed(v % 1000 === 0 ? 0 : 1)}K`
: `$${Math.round(v)}`;
/**
* The plot is memoised on purpose: hovering re-renders the card (the headline
* follows the hovered month), and re-rendering the chart with fresh props
* would restart the draw.
*/
const RevenuePlot = React.memo(function RevenuePlot({
data,
compare,
step,
gradientId,
reduced,
onActiveIndexChange,
}: {
data: RevenuePoint[];
compare: boolean;
step: number;
gradientId: string;
reduced: boolean;
onActiveIndexChange: (index: number | null) => void;
}) {
const ticks = React.useMemo(() => [0, 1, 2, 3].map((n) => n * step), [step]);
const domain = React.useMemo<[number, number]>(() => [0, step * 3], [step]);
return (
<ChartContainer config={{}} className="aspect-auto min-h-0 w-full flex-1">
<ComposedChart
data={data}
margin={CHART_MARGIN}
onMouseMove={(state) => {
const index = Number(state?.activeTooltipIndex);
if (state?.isTooltipActive && Number.isFinite(index)) {
onActiveIndexChange(index);
}
}}
onMouseLeave={() => onActiveIndexChange(null)}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--revenue-chart-fill, var(--chart-2))"
stopOpacity={0.3}
/>
<stop
offset="100%"
stopColor="var(--revenue-chart-fill, var(--chart-2))"
stopOpacity={0}
/>
</linearGradient>
</defs>
<YAxis
width={44}
domain={domain}
ticks={ticks}
tickFormatter={formatTick}
tickLine={false}
axisLine={false}
tick={Y_TICK}
/>
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={12}
interval="preserveStartEnd"
tick={X_TICK}
/>
<ChartTooltip content={renderNothing} cursor={CURSOR} />
{compare && (
<Line
type="monotone"
dataKey="previous"
stroke="var(--foreground-disabled, var(--border))"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
activeDot={false}
isAnimationActive={!reduced}
animationDuration={450}
/>
)}
<Area
type="monotone"
dataKey="current"
stroke="none"
fill={`url(#${gradientId})`}
isAnimationActive={!reduced}
animationDuration={450}
/>
<Line
type="monotone"
dataKey="current"
stroke={ACTIVE_COLOR}
strokeWidth={2.5}
dot={false}
activeDot={ACTIVE_DOT}
isAnimationActive={!reduced}
animationDuration={450}
/>
</ComposedChart>
</ChartContainer>
);
});
export function RevenueChartCard({
data: dataProp = dataDefault,
periods,
compare = true,
title = "Revenue",
className,
}: {
data?: RevenuePoint[];
/** Weekly, monthly and yearly style tabs; each swaps the series. */
periods?: RevenuePeriod[];
/** Show the dashed previous series, its legend and the comparison line. */
compare?: boolean;
title?: string;
className?: string;
}) {
const [period, setPeriod] = React.useState(periods?.[0]?.label ?? "");
const data =
periods?.find((item) => item.label === period)?.data ??
periods?.[0]?.data ??
dataProp;
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
const id = React.useId().replaceAll(":", "");
const reduced = useReducedMotion();
const point = activeIndex === null ? undefined : data[activeIndex];
const total = data.reduce((sum, item) => sum + item.current, 0);
const previous = data.reduce((sum, item) => sum + item.previous, 0);
const value = point?.current ?? total;
const comparison = point?.previous ?? previous;
const change =
comparison === 0
? null
: Math.round(((value - comparison) / comparison) * 1000) / 10;
const delta = change === null ? "New" : `${change > 0 ? "+" : ""}${change}%`;
const valueRef = useCountUp<HTMLParagraphElement>(Math.round(value), {
format: dollars,
});
const month = point ? months.indexOf(point.label) : -1;
const step = React.useMemo(() => {
const max = Math.max(
1,
...data.map((d) =>
compare ? Math.max(d.current, d.previous) : d.current
)
);
return niceStep((max * 1.02) / 3);
}, [data, compare]);
return (
<Reveal
variant="card"
render={
<Card
radius={16}
variant="muted"
// oxlint-disable-next-line shadcn/no-restyle -- reference: 24px between header and plot, 12px bottom padding
className={cn("h-86 min-w-0 flex-1 gap-6 pb-3", className)}
/>
}
>
<CardHeader className="flex w-full flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="w-full text-sm leading-5 font-medium text-muted-foreground">
{point ? (month >= 0 ? fullMonths[month] : point.label) : title}
</p>
<div className="flex w-full items-center gap-2">
<p
ref={valueRef}
className="text-2xl leading-8.5 font-medium whitespace-nowrap tabular-nums"
>
{dollars(Math.round(value))}
</p>
<Badge
variant="label"
tone={
change === null || change === 0
? "neutral"
: change > 0
? "lime"
: "rose"
}
>
{delta}
</Badge>
</div>
{compare && (
// oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: comparison line is 13px
<p className="text-[13px] leading-4.5 font-medium text-muted-foreground tabular-nums">
${money(comparison)} {point ? "a year earlier" : "last year"}
</p>
)}
</div>
{periods && (
<Tabs
value={period}
onValueChange={(next) => {
if (next) {
setPeriod(next);
setActiveIndex(null);
}
}}
>
<TabsList aria-label="Time period" variant="segmented">
{periods.map((item) => (
<TabsTrigger key={item.label} value={item.label}>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
{compare && !periods && (
// oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: legend is 13px
<dl className="flex flex-wrap items-center gap-4 text-[13px] leading-4.5 font-medium text-muted-foreground">
<div className="flex items-center gap-1.5">
<span
className="size-2 rounded-full bg-(--revenue-chart-active,var(--chart-2))"
aria-hidden="true"
/>
<dt>This year</dt>
</div>
<div className="flex items-center gap-1.5">
<span
className="size-2 rounded-full bg-(--foreground-disabled,var(--border))"
aria-hidden="true"
/>
<dt>Last year</dt>
</div>
</dl>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 gap-0">
<RevenuePlot
data={data}
compare={compare}
step={step}
gradientId={id}
reduced={reduced}
onActiveIndexChange={setActiveIndex}
/>
</CardContent>
</Reveal>
);
} On this page