Revenue Area
freeStacked area chart for MRR + one-time revenue. Editorial Density, hero number, MoM delta.
Editable — click an element to tune it
npx shadcn add @sextant/chart-revenue-areaMRR · Jun 2026
$62,700
+10.0% month over month
- Subscriptions
- One-time
Acme analytics · updated hourly
Generated code
<ChartRevenueArea />Source — this is the whole widget
"use client";
import * as React from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { ChartCard } from "@/registry/default/widget-kit/chart-card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
import { StudioTarget, useStudio } from "@/registry/default/widget-kit/studio";
import { SparkTile } from "@/registry/default/widget-kit/spark-tile";
import { useSizePlan } from "@/registry/default/widget-kit/use-chart-size";
import type { SizeProp } from "@/registry/default/widget-kit/chart-size";
import type { SizePlan } from "@/registry/default/widget-kit/size-plan";
import type {
ChartClassNames,
SeriesConfig,
} from "@/registry/default/widget-kit/contract";
import { safeData } from "@/registry/default/sextant-contracts/contracts";
import { ChartStates } from "@/registry/default/chart-states/chart-states";
import {
revenueDataSchema,
monthOverMonthDelta,
revenueData,
totalFor,
type RevenuePoint,
} from "./data";
// Re-export the data contract so consumers get the schema + types from a
// single import (no reaching into ./data).
export { revenueDataSchema, type RevenuePoint } from "./data";
/** The stacked-area series this widget draws, each independently configurable. */
export type RevenueAreaSeries = {
subscriptions?: SeriesConfig;
oneTime?: SeriesConfig;
};
type Props = {
// ── Data ────────────────────────────────────────────────────────────────
data?: RevenuePoint[];
// ── Card chrome ───────────────────────────────────────────────────────────
label?: string;
attribution?: string;
/** Shorthand for `classNames.root` — width, ring, shadow, margins, etc. */
className?: string;
/** Targeted class overrides per region (root/header/body/footnote). */
classNames?: ChartClassNames;
/** Force a size tier, or "auto" (default) to adapt to the card's own width. */
size?: SizeProp;
// ── Customize ───────────────────────────────────────────────────────────
/** Per-series color / rename / hide: `series={{ oneTime: { color: "var(--brand)" } }}`. */
series?: RevenueAreaSeries;
/** Override number formatting everywhere (axis, hero, tooltip) — e.g. EUR/locale. */
valueFormatter?: (n: number) => string;
};
const COLOR_PRESETS = [
{ name: "Maroon (brand)", value: "var(--chart-1)" },
{ name: "Ink", value: "var(--chart-2)" },
{ name: "Cool gray", value: "var(--chart-3)" },
{ name: "Sage", value: "var(--chart-positive)" },
{ name: "Terracotta", value: "var(--chart-negative)" },
{ name: "Honey", value: "var(--chart-warning)" },
];
const Y_FORMAT_OPTIONS = [
{ value: "k", label: "k-suffix", sample: "20k" },
{ value: "raw", label: "Raw", sample: "20,000" },
{ value: "currency", label: "Currency", sample: "$20k" },
];
const HERO_FORMAT_OPTIONS = [
{ value: "currency-full", label: "Full currency", sample: "$24,770" },
{ value: "currency-k", label: "k-suffix", sample: "$24.8k" },
{ value: "raw", label: "Raw", sample: "24770" },
];
// Large/Medium: the full stacked-area card (Medium a touch shorter, fewer
// ticks). Small: a glance tile — hero total + MoM delta + a sparkline of the
// total. Mobile: the full chart with the header stacked for a thumb.
const REVENUE_PLAN: SizePlan = {
lg: { shape: "full", height: "h-56", legend: "full", ticksX: 6 },
md: { shape: "full", height: "h-48", legend: "inline", ticksX: 4 },
sm: { shape: "spark" },
mobile: { shape: "full", height: "h-56", legend: "inline", ticksX: 4, stack: true },
};
function formatY(v: number, fmt: string): string {
if (fmt === "currency") {
return v >= 1000 ? `$${(v / 1000).toFixed(0)}k` : `$${v}`;
}
if (fmt === "raw") return v.toLocaleString();
return v >= 1000 ? `${(v / 1000).toFixed(0)}k` : `${v}`;
}
function formatHero(v: number, fmt: string): string {
if (fmt === "currency-k") return `$${(v / 1000).toFixed(1)}k`;
if (fmt === "raw") return v.toLocaleString();
return `$${v.toLocaleString()}`;
}
/** Round the data max up to a clean ceiling and emit evenly-spaced round ticks,
* so the Y axis reads 0/10k/20k/30k instead of Recharts' raw 0/7k/13k/26k. */
function niceScale(max: number, count = 4): { max: number; ticks: number[] } {
if (max <= 0) return { max: 1, ticks: [0, 1] };
const rawStep = max / count;
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
const norm = rawStep / mag;
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10) * mag;
const niceMax = Math.ceil(max / step) * step;
const ticks: number[] = [];
for (let v = 0; v <= niceMax + step * 0.5; v += step) ticks.push(v);
return { max: niceMax, ticks };
}
export function ChartRevenueArea(props: Props) {
// Validate consumer data at the boundary: a bad shape shows the error
// state instead of crashing. The built-in demo data is trusted, so skip it.
const parsed =
props.data === undefined ? null : safeData(revenueDataSchema, props.data);
if (parsed && !parsed.ok) {
return (
<ChartStates status="error" shape="area" error={{ message: parsed.error }}>
{null}
</ChartStates>
);
}
return <ChartRevenueAreaImpl {...props} />;
}
function ChartRevenueAreaImpl({
data = revenueData,
label,
attribution,
className,
classNames,
size = "auto",
series,
valueFormatter,
}: Props) {
const studio = useStudio();
// Editable fields via Studio. In production (no provider), these
// resolve to defaults and don't add any DOM overhead.
const title = studio.text("title", label ?? "MRR · Jun 2026", {
label: "Chart title",
placeholder: "MRR · Jun 2026",
});
const heroFmt = studio.format("heroFormat", "currency-full", {
label: "Hero number format",
options: HERO_FORMAT_OPTIONS,
});
const yFmt = studio.format("yAxisFormat", "k", {
label: "Y-axis format",
options: Y_FORMAT_OPTIONS,
});
const showDelta = studio.toggle("showDelta", true, {
label: "Show MoM delta",
onLabel: "Visible",
offLabel: "Hidden",
});
// `series.*.label` / `series.*.color` set the defaults; Studio (when mounted)
// still edits on top of them.
const subscriptionsName = studio.text(
"subscriptionsName",
series?.subscriptions?.label ?? "Subscriptions",
{ label: "Series 1 name" },
);
const oneTimeName = studio.text("oneTimeName", series?.oneTime?.label ?? "One-time", {
label: "Series 2 name",
});
const subscriptionsColor = studio.color(
"subscriptionsColor",
series?.subscriptions?.color ?? "var(--chart-1)",
{ label: "Series 1 color", presets: COLOR_PRESETS },
);
const oneTimeColor = studio.color(
"oneTimeColor",
series?.oneTime?.color ?? "var(--chart-3)",
{ label: "Series 2 color", presets: COLOR_PRESETS },
);
const attributionText = studio.text(
"attribution",
attribution ?? "Acme analytics · updated hourly",
{ label: "Attribution" },
);
const subscriptionsHidden = series?.subscriptions?.hidden ?? false;
const oneTimeHidden = series?.oneTime?.hidden ?? false;
const config = {
subscriptions: { label: subscriptionsName, color: subscriptionsColor },
oneTime: { label: oneTimeName, color: oneTimeColor },
} satisfies ChartConfig;
const delta = monthOverMonthDelta(data);
const latest = data[data.length - 1];
return (
<ChartCard.Root
size={size}
className={cn(className, classNames?.root)}
exportable
exportTitle={title}
>
<RevenueInner
data={data}
config={config}
title={title}
heroFmt={heroFmt}
yFmt={yFmt}
showDelta={showDelta}
delta={delta}
latest={latest}
subscriptionsName={subscriptionsName}
oneTimeName={oneTimeName}
subscriptionsColor={subscriptionsColor}
oneTimeColor={oneTimeColor}
subscriptionsHidden={subscriptionsHidden}
oneTimeHidden={oneTimeHidden}
valueFormatter={valueFormatter}
attributionText={attributionText}
classNames={classNames}
/>
</ChartCard.Root>
);
}
type InnerProps = {
data: RevenuePoint[];
config: ChartConfig;
title: string;
heroFmt: string;
yFmt: string;
showDelta: boolean;
delta: number;
latest: RevenuePoint;
subscriptionsName: string;
oneTimeName: string;
subscriptionsColor: string;
oneTimeColor: string;
subscriptionsHidden: boolean;
oneTimeHidden: boolean;
valueFormatter?: (n: number) => string;
attributionText: string;
classNames?: ChartClassNames;
};
function RevenueInner({
data,
config,
title,
heroFmt,
yFmt,
showDelta,
delta,
latest,
subscriptionsName,
oneTimeName,
subscriptionsColor,
oneTimeColor,
subscriptionsHidden,
oneTimeHidden,
valueFormatter,
attributionText,
classNames,
}: InnerProps) {
const spec = useSizePlan(REVENUE_PLAN);
// When provided, `valueFormatter` overrides the hero/spark number format;
// otherwise the Studio-driven `formatHero(_, heroFmt)` is used unchanged.
const formatHeroValue = valueFormatter ?? ((v: number) => formatHero(v, heroFmt));
if (spec.shape === "spark") {
const sparkData = data.map((p) => ({ date: p.date, v: totalFor(p) }));
return (
<SparkTile
label={title}
value={formatHeroValue(totalFor(latest))}
chip={<ChartCard.Delta value={delta} />}
spark={
<ChartContainer config={config} className="h-full w-full">
<AreaChart
data={sparkData}
margin={{ top: 2, right: 0, bottom: 0, left: 0 }}
>
<Area
dataKey="v"
type="monotone"
stroke={subscriptionsColor}
strokeWidth={1.5}
fill={subscriptionsColor}
fillOpacity={0.12}
isAnimationActive={false}
dot={false}
/>
</AreaChart>
</ChartContainer>
}
/>
);
}
const legendItems = [
subscriptionsHidden
? null
: { label: subscriptionsName, color: subscriptionsColor },
oneTimeHidden ? null : { label: oneTimeName, color: oneTimeColor },
].filter((it): it is { label: string; color: string } => it !== null);
return (
<>
<ChartCard.Header className={classNames?.header}>
<ChartCard.HeaderMain>
<StudioTarget fieldKey="title" variant="inline">
<ChartCard.Label>{title}</ChartCard.Label>
</StudioTarget>
<StudioTarget fieldKey="heroFormat" variant="inline">
<ChartCard.HeroNumber>
{formatHeroValue(totalFor(latest))}
</ChartCard.HeroNumber>
</StudioTarget>
{showDelta && (
<StudioTarget fieldKey="showDelta" variant="inline">
<ChartCard.Delta value={delta}>month over month</ChartCard.Delta>
</StudioTarget>
)}
</ChartCard.HeaderMain>
{(spec.legend === "full" || spec.legend === "inline") &&
legendItems.length > 0 && <ChartCard.Legend items={legendItems} />}
</ChartCard.Header>
<ChartCard.Body className={classNames?.body}>
<ChartRevenueAreaPlot
data={data}
config={config}
yFmt={yFmt}
subscriptionsColor={subscriptionsColor}
oneTimeColor={oneTimeColor}
heightClass={spec.height}
ticksX={spec.ticksX}
valueFormatter={valueFormatter}
hidden={{ subscriptions: subscriptionsHidden, oneTime: oneTimeHidden }}
/>
</ChartCard.Body>
<StudioTarget fieldKey="attribution" variant="inline">
<ChartCard.Footnote className={classNames?.footnote}>
{attributionText}
</ChartCard.Footnote>
</StudioTarget>
</>
);
}
type PlotProps = {
data: RevenuePoint[];
config: ChartConfig;
yFmt: string;
/** Subscriptions stroke/fill. Falls back to `config.subscriptions.color`. */
subscriptionsColor?: string;
/** One-time stroke/fill. Falls back to `config.oneTime.color`. */
oneTimeColor?: string;
heightClass?: string;
ticksX?: number;
/** Override axis + tooltip number formatting (e.g. EUR/locale). */
valueFormatter?: (n: number) => string;
/** Hide individual series (default: all shown). */
hidden?: { subscriptions?: boolean; oneTime?: boolean };
};
/**
* Just the chart-drawing piece. Use this directly if you want a different
* card chrome, or if you want to embed multiple Plots in a custom layout.
* Colors come from `config` (keyed by `subscriptions` / `oneTime`); the
* explicit color props override it when given.
*/
export function ChartRevenueAreaPlot({
data,
config,
yFmt,
subscriptionsColor,
oneTimeColor,
heightClass = "h-56",
ticksX = 6,
valueFormatter,
hidden,
}: PlotProps) {
const subsColor =
subscriptionsColor ?? config.subscriptions?.color ?? "var(--chart-1)";
const oneColor = oneTimeColor ?? config.oneTime?.color ?? "var(--chart-3)";
const { max: yMax, ticks: yTicks } = React.useMemo(
() => niceScale(Math.max(0, ...data.map((p) => totalFor(p)))),
[data],
);
// Keep the Studio-driven `formatY(_, yFmt)` axis by default; only swap the tick
// formatting (not the niceScale domain/ticks) when `valueFormatter` is given.
const formatAxis = valueFormatter ?? ((v: number) => formatY(v, yFmt));
// The tooltip's `formatter` replaces the whole row, so when overriding the
// value format we re-emit the dot + label to match the default look.
// Always format — even with no `valueFormatter` — so the tooltip matches the
// axis instead of falling back to the generic raw-number, raw-dataKey dump.
const tooltipFormatter: React.ComponentProps<
typeof ChartTooltipContent
>["formatter"] = (value, name, item) => (
<>
<span
aria-hidden
className="h-2.5 w-2.5 shrink-0 rounded-[2px]"
style={{ backgroundColor: (item as { color?: string }).color }}
/>
<span className="flex flex-1 items-center justify-between gap-3 leading-none">
<span className="text-muted-foreground">
{config[name as string]?.label ?? String(name)}
</span>
<span className="font-mono font-medium text-foreground tabular-nums">
{formatAxis(Number(value))}
</span>
</span>
</>
);
return (
<ChartContainer config={config} className={cn(heightClass, "w-full")}>
<AreaChart data={data} margin={{ left: 0, right: 0, top: 4 }}>
<defs>
<linearGradient id="cra-fillSubs" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={subsColor} stopOpacity={0.45} />
<stop offset="95%" stopColor={subsColor} stopOpacity={0.02} />
</linearGradient>
<linearGradient id="cra-fillOne" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={oneColor} stopOpacity={0.3} />
<stop offset="95%" stopColor={oneColor} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid
vertical={false}
stroke="var(--chart-grid)"
strokeDasharray="0"
/>
<XAxis
dataKey="date"
tickFormatter={(v: string) => format(parseISO(v), "MMM").toUpperCase()}
tickLine={false}
axisLine={false}
tickMargin={10}
fontSize={10}
minTickGap={ticksX >= 6 ? 28 : ticksX >= 4 ? 40 : 56}
className="smallcaps"
/>
<YAxis
domain={[0, yMax]}
ticks={yTicks}
tickFormatter={(v: number) => formatAxis(v)}
tickLine={false}
axisLine={false}
width={36}
fontSize={10}
className="smallcaps tabular"
/>
<ChartTooltip
cursor={{
stroke: "var(--foreground)",
strokeOpacity: 0.25,
strokeDasharray: "2 4",
}}
content={
<ChartTooltipContent
labelFormatter={(v) => format(parseISO(String(v)), "MMMM yyyy")}
formatter={tooltipFormatter}
/>
}
/>
{!hidden?.subscriptions && (
<Area
dataKey="subscriptions"
type="monotone"
stackId="rev"
stroke={subsColor}
strokeWidth={1.5}
fill="url(#cra-fillSubs)"
/>
)}
{!hidden?.oneTime && (
<Area
dataKey="oneTime"
type="monotone"
stackId="rev"
stroke={oneColor}
strokeWidth={1.5}
fill="url(#cra-fillOne)"
/>
)}
</AreaChart>
</ChartContainer>
);
}