Charts 2: revenue report
A revenue report: monthly recurring revenue, annual run rate, what was added last month and the largest plan, then revenue by plan as stacked columns and net revenue retention as a line against the 100% mark.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// Ballmac UI: Charts 2. https://ui.ballmac.com/blocks/charts-2
"use client"
import * as React from "react"
import { ArrowDown, ArrowUp, CircleDollarSign } from "lucide-react"
import { ChartBar } from "@/components/ballmac/chart-bar"
import { ChartLine } from "@/components/ballmac/chart-line"
import { KpiItem, KpiRow } from "@/components/ballmac/kpi-row"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"
/** One month of recurring revenue by plan, and what share of last year's revenue from the same customers is still there. */
type Charts2Month = { month: string; starter: number; pro: number; team: number; retention: number }
type Charts2Props = Omit<React.ComponentProps<"section">, "title"> &
StateProps & {
title?: string
description?: string
/** One row per month, oldest first, with the month written for people ("Jan"). At least two. */
months?: Charts2Month[]
/** ISO currency code. */
currency?: string
locale?: string
}
const sample: Charts2Month[] = [
{ month: "Nov", starter: 12400, pro: 31800, team: 18200, retention: 101 },
{ month: "Dec", starter: 12900, pro: 33600, team: 19900, retention: 102 },
{ month: "Jan", starter: 13300, pro: 34900, team: 22400, retention: 100 },
{ month: "Feb", starter: 13600, pro: 36800, team: 24100, retention: 103 },
{ month: "Mar", starter: 14100, pro: 38900, team: 26800, retention: 105 },
{ month: "Apr", starter: 14200, pro: 41200, team: 29300, retention: 104 },
{ month: "May", starter: 14800, pro: 43100, team: 32600, retention: 106 },
{ month: "Jun", starter: 15100, pro: 45800, team: 35400, retention: 108 },
{ month: "Jul", starter: 15400, pro: 47200, team: 38900, retention: 107 },
{ month: "Aug", starter: 15200, pro: 49600, team: 42100, retention: 109 },
{ month: "Sep", starter: 15900, pro: 52300, team: 45800, retention: 111 },
{ month: "Oct", starter: 16300, pro: 54700, team: 49600, retention: 112 },
]
function Skel() {
return (
<div aria-hidden="true" className="grid gap-6">
<Skeleton className="h-24 rounded-xl" />
<Skeleton className="aspect-[2/1] w-full rounded-xl" />
<Skeleton className="aspect-[2/1] w-full rounded-xl" />
</div>
)
}
/**
* A revenue report: monthly recurring revenue, annual run rate, what was added last month and the best plan, then revenue by plan as stacked columns and
* net revenue retention as a line against the 100% mark. Every figure is worked out from the monthly rows, and each chart has a table view and a CSV download.
*/
function Charts2({
title = "Revenue",
description = "Recurring revenue by plan, and whether existing customers spend more or less over time.",
months = sample,
currency = "USD",
locale = "en",
state = "ready",
onRetry,
emptyState,
errorState,
className,
...props
}: Charts2Props) {
const id = React.useId()
const rows = state === "empty" ? [] : months
const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }), [locale, currency])
const compact = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency, notation: "compact", maximumFractionDigits: 1 }), [locale, currency])
const pct = React.useMemo(() => new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1, signDisplay: "exceptZero" }), [locale])
const total = (m: Charts2Month) => m.starter + m.pro + m.team
const last = rows.at(-1)
const prev = rows.at(-2)
const mrr = last ? total(last) : 0
const added = last && prev ? total(last) - total(prev) : null
const plans = [
{ key: "starter", label: "Starter" },
{ key: "pro", label: "Pro" },
{ key: "team", label: "Team" },
] as const
const top = last ? [...plans].sort((a, b) => last[b.key] - last[a.key])[0]! : null
const content = !last ? null : (
<div className="grid gap-6">
<KpiRow columns={4} className="w-full">
<KpiItem label="Monthly recurring revenue" value={money.format(mrr)} detail={added === null ? undefined : <span className="inline-flex items-center gap-1">{added >= 0 ? <ArrowUp aria-hidden="true" className="size-3.5 text-chart-2" /> : <ArrowDown aria-hidden="true" className="text-destructive size-3.5" />}<span className="text-foreground font-medium tabular-nums">{pct.format(added / (total(prev!) || 1))}</span><span className="sr-only">{added >= 0 ? " up" : " down"}</span> from {prev!.month}</span>} />
<KpiItem label="Annual run rate" value={money.format(mrr * 12)} detail="Monthly revenue times twelve" />
<KpiItem label={`Added in ${last.month}`} value={added === null ? "–" : `${added >= 0 ? "+" : "−"}${money.format(Math.abs(added))}`} detail={added === null ? "No earlier month" : "Net of what customers dropped"} />
<KpiItem label="Largest plan" value={top!.label} detail={`${money.format(last[top!.key])} a month, ${new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 }).format(last[top!.key] / (mrr || 1))} of the total`} />
</KpiRow>
<ChartBar
title="Revenue by plan"
description={`Monthly recurring revenue, ${rows[0]!.month} to ${last.month}`}
data={rows}
xKey="month"
categoryLabel="Month"
stack="stacked"
series={plans.map((p) => ({ key: p.key, label: p.label }))}
axisFormatter={(v) => compact.format(v)}
valueFormatter={(v) => money.format(v)}
locale={locale}
aspect="panorama"
gap="tight"
/>
<ChartLine
title="Net revenue retention"
description="Revenue from customers who were here a year ago, against what they paid then. Above 100% means they grow."
data={rows}
xKey="month"
categoryLabel="Month"
series={[{ key: "retention", label: "Retention", color: "var(--chart-2)" }]}
yDomain={[90, 120]}
yFormatter={(v) => `${v}%`}
valueFormatter={(v) => `${v}%`}
referenceLines={[{ y: 100, label: "100%: flat" }]}
locale={locale}
aspect="panorama"
dots
/>
</div>
)
return (
<section data-slot="charts-2" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-5xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}>
<header className="mb-8">
<h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance">
{title}
</h1>
<p className="text-muted-foreground mt-2 max-w-2xl text-pretty">{description}</p>
</header>
<StateView
state={state === "empty" || !rows.length ? "empty" : state === "loading" || state === "error" ? state : "ready"}
onRetry={onRetry}
label="Loading revenue"
loading={<Skel />}
empty={{ icon: <CircleDollarSign />, title: emptyState?.title ?? "No revenue yet", description: emptyState?.description ?? "Once you have paying customers, revenue and retention appear here.", action: emptyState?.action }}
error={{ title: errorState?.title ?? "Revenue could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }}
>
{content}
</StateView>
</section>
)
}
export { Charts2, type Charts2Props, type Charts2Month }Installation
$ pnpm dlx shadcn@latest add @ballmac/charts-2Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/chart-bar @ballmac/chart-line @ballmac/kpi-row @ballmac/skeleton @ballmac/state-viewCopy the source into your project.
components/ballmac/blocks/charts-2/charts-2.tsx// Ballmac UI: Charts 2. https://ui.ballmac.com/blocks/charts-2 "use client" import * as React from "react" import { ArrowDown, ArrowUp, CircleDollarSign } from "lucide-react" import { ChartBar } from "@/components/ballmac/chart-bar" import { ChartLine } from "@/components/ballmac/chart-line" import { KpiItem, KpiRow } from "@/components/ballmac/kpi-row" import { Skeleton } from "@/components/ballmac/skeleton" import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view" import { cn } from "@/lib/utils" /** One month of recurring revenue by plan, and what share of last year's revenue from the same customers is still there. */ type Charts2Month = { month: string; starter: number; pro: number; team: number; retention: number } type Charts2Props = Omit<React.ComponentProps<"section">, "title"> & StateProps & { title?: string description?: string /** One row per month, oldest first, with the month written for people ("Jan"). At least two. */ months?: Charts2Month[] /** ISO currency code. */ currency?: string locale?: string } const sample: Charts2Month[] = [ { month: "Nov", starter: 12400, pro: 31800, team: 18200, retention: 101 }, { month: "Dec", starter: 12900, pro: 33600, team: 19900, retention: 102 }, { month: "Jan", starter: 13300, pro: 34900, team: 22400, retention: 100 }, { month: "Feb", starter: 13600, pro: 36800, team: 24100, retention: 103 }, { month: "Mar", starter: 14100, pro: 38900, team: 26800, retention: 105 }, { month: "Apr", starter: 14200, pro: 41200, team: 29300, retention: 104 }, { month: "May", starter: 14800, pro: 43100, team: 32600, retention: 106 }, { month: "Jun", starter: 15100, pro: 45800, team: 35400, retention: 108 }, { month: "Jul", starter: 15400, pro: 47200, team: 38900, retention: 107 }, { month: "Aug", starter: 15200, pro: 49600, team: 42100, retention: 109 }, { month: "Sep", starter: 15900, pro: 52300, team: 45800, retention: 111 }, { month: "Oct", starter: 16300, pro: 54700, team: 49600, retention: 112 }, ] function Skel() { return ( <div aria-hidden="true" className="grid gap-6"> <Skeleton className="h-24 rounded-xl" /> <Skeleton className="aspect-[2/1] w-full rounded-xl" /> <Skeleton className="aspect-[2/1] w-full rounded-xl" /> </div> ) } /** * A revenue report: monthly recurring revenue, annual run rate, what was added last month and the best plan, then revenue by plan as stacked columns and * net revenue retention as a line against the 100% mark. Every figure is worked out from the monthly rows, and each chart has a table view and a CSV download. */ function Charts2({ title = "Revenue", description = "Recurring revenue by plan, and whether existing customers spend more or less over time.", months = sample, currency = "USD", locale = "en", state = "ready", onRetry, emptyState, errorState, className, ...props }: Charts2Props) { const id = React.useId() const rows = state === "empty" ? [] : months const money = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }), [locale, currency]) const compact = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency, notation: "compact", maximumFractionDigits: 1 }), [locale, currency]) const pct = React.useMemo(() => new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1, signDisplay: "exceptZero" }), [locale]) const total = (m: Charts2Month) => m.starter + m.pro + m.team const last = rows.at(-1) const prev = rows.at(-2) const mrr = last ? total(last) : 0 const added = last && prev ? total(last) - total(prev) : null const plans = [ { key: "starter", label: "Starter" }, { key: "pro", label: "Pro" }, { key: "team", label: "Team" }, ] as const const top = last ? [...plans].sort((a, b) => last[b.key] - last[a.key])[0]! : null const content = !last ? null : ( <div className="grid gap-6"> <KpiRow columns={4} className="w-full"> <KpiItem label="Monthly recurring revenue" value={money.format(mrr)} detail={added === null ? undefined : <span className="inline-flex items-center gap-1">{added >= 0 ? <ArrowUp aria-hidden="true" className="size-3.5 text-chart-2" /> : <ArrowDown aria-hidden="true" className="text-destructive size-3.5" />}<span className="text-foreground font-medium tabular-nums">{pct.format(added / (total(prev!) || 1))}</span><span className="sr-only">{added >= 0 ? " up" : " down"}</span> from {prev!.month}</span>} /> <KpiItem label="Annual run rate" value={money.format(mrr * 12)} detail="Monthly revenue times twelve" /> <KpiItem label={`Added in ${last.month}`} value={added === null ? "–" : `${added >= 0 ? "+" : "−"}${money.format(Math.abs(added))}`} detail={added === null ? "No earlier month" : "Net of what customers dropped"} /> <KpiItem label="Largest plan" value={top!.label} detail={`${money.format(last[top!.key])} a month, ${new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 }).format(last[top!.key] / (mrr || 1))} of the total`} /> </KpiRow> <ChartBar title="Revenue by plan" description={`Monthly recurring revenue, ${rows[0]!.month} to ${last.month}`} data={rows} xKey="month" categoryLabel="Month" stack="stacked" series={plans.map((p) => ({ key: p.key, label: p.label }))} axisFormatter={(v) => compact.format(v)} valueFormatter={(v) => money.format(v)} locale={locale} aspect="panorama" gap="tight" /> <ChartLine title="Net revenue retention" description="Revenue from customers who were here a year ago, against what they paid then. Above 100% means they grow." data={rows} xKey="month" categoryLabel="Month" series={[{ key: "retention", label: "Retention", color: "var(--chart-2)" }]} yDomain={[90, 120]} yFormatter={(v) => `${v}%`} valueFormatter={(v) => `${v}%`} referenceLines={[{ y: 100, label: "100%: flat" }]} locale={locale} aspect="panorama" dots /> </div> ) return ( <section data-slot="charts-2" data-state={state} aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-5xl px-4 py-10 sm:px-6", className)} {...frameProps(props)}> <header className="mb-8"> <h1 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance"> {title} </h1> <p className="text-muted-foreground mt-2 max-w-2xl text-pretty">{description}</p> </header> <StateView state={state === "empty" || !rows.length ? "empty" : state === "loading" || state === "error" ? state : "ready"} onRetry={onRetry} label="Loading revenue" loading={<Skel />} empty={{ icon: <CircleDollarSign />, title: emptyState?.title ?? "No revenue yet", description: emptyState?.description ?? "Once you have paying customers, revenue and retention appear here.", action: emptyState?.action }} error={{ title: errorState?.title ?? "Revenue could not be loaded", description: errorState?.description ?? "Check your connection and try again.", ...errorState }} > {content} </StateView> </section> ) } export { Charts2, type Charts2Props, type Charts2Month }Update the import paths to match your project setup.
Usage
"use client"
import { Charts2 } from "@/components/ballmac/blocks/charts-2/charts-2"
export default function Charts2Demo() {
return <Charts2 />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
Skeletons shaped like the figures and the two charts, under the real heading, announced as loading.
state="loading"No revenue yet
Says revenue appears once there are paying customers; emptyState sets the copy.
state="empty" (or months={[]})Could not load
An alert with Try again (onRetry).
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 4 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (1% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
8 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, empty, error states, each with an example.
Passed: Automated tests
Covered by charts-2.test.tsx.
For information: Footprint
2.7 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
<Charts2 title description months={[{ month, starter, pro, team, retention }]} currency='USD' locale state />. One row per month, oldest first. Monthly recurring revenue, annual run rate, the month's net addition and the largest plan are worked out from the rows; plans are fixed to starter, pro and team in this version, so rename them in the source for your own tiers. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Charts 2: revenue report (@ballmac/charts-2) to this project with the shadcn MCP, then use it where it fits.
Use it for
- A finance or SaaS revenue page
- Showing growth by plan next to retention
Not for
- Traffic and conversion (use charts-1)
- A pricing table (use pricing-1)
Registry JSON: https://ui.ballmac.com/r/charts-2.json
Free to use in personal and commercial projects.
More charts blocks
Charts 1: analytics overview
An analytics overview: a range picker, four figures with their change from the period before, a stacked area chart of new and returning visitors, top pages and a sources donut, each with a table view and CSV download.
Charts 3: small multiples
Small multiples: one small area chart per metric with the latest value, its change over the period in words and an arrow, which direction is good news, and a written summary for screen readers.
Charts 4: scorecard
A quarterly scorecard: goals met and average progress, concentric rings for the goals, a radar of the team against its targets and a ranked bar chart, with a table view and CSV download on every chart.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.