Sextant
← Playground

Revenue Area

free

Stacked 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-area

MRR · 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>
  );
}