BudgetVsActual
- Status
- Implemented
- Family
- 13 of 13
- Registry
- @lopifi/budget-vs-actual
npx shadcn@latest add @lopifi/theme @lopifi/budget-vs-actualThe component, rendering
Real data, the shipped component. Switch the theme to check the claims the spec makes below.
The four-layer spec
Actual, budget, variance (absolute and relative), and forecast, by period and by dimension. Respects polarity — a favorable expense variance is under budget. Materiality thresholds applied, with the threshold displayed. Reforecast versions are tracked so you can see what was believed when.
The variance is a view, not a column. Actual, Δ and Δ% are the same three figures asked three questions, and putting all of them on screen at once is how a budget review becomes a wall of numbers nobody reads to the end. The comparison is declared rather than positional: the actual column names budget as its base, so hiding the budget column to fit the screen does not silently re-point the variance at whatever ended up to its left.
Two things make this more than a three-column table, and both are here because a budget review goes wrong without them.
The cut. A favourable total is routinely two departments cancelling out. Engineering 1.6M over and sales 1.7M under reads, at the entity line, as 0.1M under — favourable — and says nothing about either. A variance report that can only be read at the entity level is one that will be believed for a quarter longer than it should be. Members are not required to sum to the entity — unallocated corporate spend sits outside them — and a vintage that restates an entity budget says nothing about one department's share, so under a cut the member's own budget stands rather than the component inventing an allocation nobody approved.
The vintage. The original plan and each reforecast are versions of one object, not "budget" plus "reforecasts": model a reforecast as a different kind of thing and "what did we believe in February" stops having a single answer. A vintage states only what it changed. The comparison base is chosen out loud in the toolbar, and the walk between versions sits under the grid, because a reforecast that quietly replaces the plan makes every past month look better than it was called at the time. September beating the version in force while missing the one the board saw in November are both true readings; a component that picked one silently would be picking the flattering one.
Lifted verbatim from Metrics & analysis. The doc and this page are the same paragraphs, so they cannot disagree.
What lands in your repository
One component is never one file. Everything below is copied into your project, where you own it.
- Files
components/ledger/budget-vs-actual.tsx
- Lopifi dependencies · 17
- clipboardcncolumn-managerfiscal-periodforecastformat-moneyiconsinfo-tipmoneymoney.typesperiod-changeperiod-gridruletooltipuse-column-widthsuse-grid-navuse-scroll-edges
- npm packages
- @base-ui-components/reactclass-variance-authorityclsxtailwind-merge
- Registry entry
- /r/budget-vs-actual.json
The whole component
No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.
"use client";
import * as React from "react";
import { cn } from "@/lib/ledger/cn";
import { formatDate } from "@/lib/ledger/fiscal-period";
import { budgetForVersion } from "@/lib/ledger/forecast";
import { formatMoney } from "@/lib/ledger/format-money";
import type { Polarity } from "@/lib/ledger/money.types";
import { InfoTip } from "@/components/ledger/info-tip";
import { Money } from "@/components/ledger/money";
import type { PeriodColumn, PeriodRow } from "@/components/ledger/period-grid";
import { PeriodGrid } from "@/components/ledger/period-grid";
import { Rule } from "@/components/ledger/rule";
/** One figure set — a line, or a line cut to one member of a dimension. */
export type BvaFigures = {
actual: number | null;
budget: number | null;
forecast?: number | null;
};
export type BvaLine = BvaFigures & {
id: string;
label: string;
level: number;
variant: "line" | "subtotal" | "total" | "section";
/**
* Which way is good, per line, and the reason this component is not a table.
*
* Polarity describes the figure **as stored**, not the concept behind it.
* This system holds costs as negatives, so an overspent month is a figure
* moving *down* and the row wants `standard`. It is required rather than
* defaulted because revenue under budget and payroll under budget are
* opposite outcomes, and a single favourable/unfavourable rule applied to
* both gets one of them exactly backwards.
*/
polarity: Polarity;
/** The same three figures cut by dimension member id. */
byMember?: Record<string, BvaFigures>;
};
/**
* A budget as it stood on a date.
*
* The original plan and each reforecast are versions of the same object, held
* in one list rather than as "budget" plus "reforecasts": the moment you model
* a reforecast as a different kind of thing from the plan, the question "what
* did we believe in February" stops having a single answer.
*/
export type BudgetVersion = {
id: string;
/** "FY26 Plan v1", "Q2 reforecast". Named, because an unnamed budget is a rumour. */
name: string;
approvedOn: Date;
/**
* What this version budgeted, per line id. A line absent here falls back to
* the line's own `budget` — so a vintage only has to state what it changed.
*/
budget?: Record<string, number | null>;
};
export type BvaDimension = {
/** "Department", "Region", "Product". Shown as the switch's label. */
label: string;
members: { id: string; label: string }[];
};
export interface BudgetVsActualProps {
className?: string;
currency: string;
/** Offering the dimension cut. Omit and the grid is the entity total. */
dimension?: BvaDimension;
lines: BvaLine[];
materiality: { absolute: number; relative: number };
periodLabel: string;
storageKey?: string;
/**
* Every version of the budget, oldest first. The last is the one in force
* and the one selected by default.
*/
versions: BudgetVersion[];
}
const ALL = "__all__";
/**
* Budget, actual and forecast as one grid rather than five columns.
*
* The variance is not a column here; it is a *view*. Actual, Δ and Δ% are the
* same three figures asked three questions, and putting all of them on screen
* at once is how a budget review becomes a wall of numbers nobody reads to the
* end. The switch is in the toolbar, and the hover chip answers the question
* for one line without leaving the value view at all.
*
* The comparison is declared, not positional: the actual column names budget
* as its base. Hide the budget column to fit the screen and the variance still
* measures against budget rather than quietly re-pointing at whatever column
* ended up to its left.
*
* Two things make this more than a three-column table, and both are here
* because a budget review goes wrong without them:
*
* 1. **The cut.** A favourable total is routinely two departments cancelling
* out, and a variance report that can only be read at the entity level is a
* report that will be believed for a quarter longer than it should be.
* 2. **The vintage.** A reforecast that quietly replaces the plan makes every
* past month look better than it was called at the time. The versions are
* kept, the comparison base is chosen explicitly, and the walk from the
* original plan to the version in force is shown underneath — so "we said
* we would land at 56 in November and we are calling 60.4 now" is a
* sentence the grid can support.
*/
export function BudgetVsActual({
periodLabel,
lines,
currency,
materiality,
versions,
dimension,
storageKey,
className,
}: BudgetVsActualProps) {
const current = versions[versions.length - 1];
const [versionId, setVersionId] = React.useState(current?.id ?? "");
const [member, setMember] = React.useState(ALL);
const selected = versions.find((v) => v.id === versionId) ?? current;
/* The figures for the cut in force. A member with nothing posted reads as
null throughout rather than zero — "this department did not spend here"
and "this department spent nothing here" are different findings, and only
one of them is a variance. */
const figuresFor = React.useCallback(
(line: BvaLine): BvaFigures =>
member === ALL
? { actual: line.actual, budget: line.budget, forecast: line.forecast }
: (line.byMember?.[member] ?? { actual: null, budget: null, forecast: null }),
[member],
);
/* The budget in force for a line, under the selected vintage. */
const budgetFor = React.useCallback(
(line: BvaLine, figures: BvaFigures): number | null => {
/* A vintage restates entity-level budgets. Under a dimension cut it has
nothing to say about one department's share, so the cut's own budget
stands — inventing a split would be the component asserting an
allocation nobody approved. */
if (member !== ALL) return figures.budget;
return budgetForVersion(figures.budget, selected?.budget, line.id);
},
[member, selected],
);
const hasForecast = lines.some((l) => figuresFor(l).forecast != null);
const columns: PeriodColumn[] = [
{
hint: selected ? `${selected.name} · approved ${formatDate(selected.approvedOn)}` : undefined,
id: "budget",
kind: "budget",
label: "Budget",
},
{ compare: "budget", id: "actual", kind: "actual", label: "Actual", locked: true },
...(hasForecast
? [{ compare: "budget", id: "forecast", kind: "forecast" as const, label: "Forecast" }]
: []),
];
const rows: PeriodRow[] = lines.map((l) => {
const figures = figuresFor(l);
const section = l.variant === "section";
return {
id: l.id,
label: l.variant === "total" ? `${l.label}, grand total` : l.label,
level: l.level,
polarity: l.polarity,
values: [
section ? null : budgetFor(l, figures),
section ? null : figures.actual,
...(hasForecast ? [section ? null : (figures.forecast ?? null)] : []),
],
variant: l.variant,
};
});
/* The line the vintage walk is told about. The grand total if there is one,
otherwise the last line — a walk needs one measure, and picking the
bottom of the statement is the convention every board pack follows. */
const anchor = React.useMemo(
() => [...lines].reverse().find((l) => l.variant === "total") ?? lines[lines.length - 1],
[lines],
);
const showWalk = versions.length > 1 && anchor != null && member === ALL;
return (
<div className={cn("space-y-4", className)}>
<PeriodGrid
caption={`Budget versus actual, ${periodLabel}${
member === ALL ? "" : ` · ${dimension?.members.find((m) => m.id === member)?.label}`
}`}
columns={columns}
currency={currency}
footnote={
<>
{periodLabel} · measured against{" "}
<span className="font-medium text-text-secondary">{selected?.name}</span>
{selected ? <>, approved {formatDate(selected.approvedOn)}</> : null} · variances over{" "}
<Money size="xs" tone="inherit" value={{ amount: materiality.absolute, currency }} /> <em>and</em>{" "}
<span className="numeric">{materiality.relative}%</span> are marked ▸
{member === ALL ? null : (
<> · one {dimension?.label.toLowerCase()} only, so these lines do not sum to the entity</>
)}
</>
}
labelHeader="Line"
labelWidth={260}
materiality={materiality}
rows={rows}
storageKey={storageKey}
toolbar={
versions.length > 1 || dimension ? (
<div className="flex flex-wrap items-center gap-4">
{versions.length > 1 ? (
<label className="flex items-center gap-2">
<span className="text-label font-medium text-text-secondary">Compare to</span>
<select
className="h-8 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle"
onChange={(e) => setVersionId(e.target.value)}
value={versionId}
>
{[...versions].reverse().map((v) => (
<option key={v.id} value={v.id}>
{v.name}
{v.id === current?.id ? " (in force)" : ""}
</option>
))}
</select>
</label>
) : null}
{dimension ? (
<label className="flex items-center gap-2">
<span className="text-label font-medium text-text-secondary">{dimension.label}</span>
<select
className="h-8 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle"
onChange={(e) => setMember(e.target.value)}
value={member}
>
<option value={ALL}>All — entity total</option>
{dimension.members.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
</label>
) : null}
</div>
) : null
}
/>
{showWalk ? (
<div className="rounded-xl border border-border bg-surface p-6">
<h3 className="text-title-sm font-medium">
What was believed, and when
<InfoTip about="Why every version is kept">
A reforecast that replaces the plan makes every past month look better than it was called at the
time. Keeping the versions is what lets someone ask whether the number moved because the
business did, or because the target did.
</InfoTip>
</h3>
<Rule className="mt-3" variant="subtotal" />
<p className="mt-3 text-caption text-text-tertiary">
{anchor.label}, {periodLabel}, as each version had it.
</p>
<ul className="mt-3 space-y-2">
{versions.map((v, i) => {
const value = budgetForVersion(anchor.budget, v.budget, anchor.id);
const previous = versions[i - 1];
const previousValue =
previous == null ? null : budgetForVersion(anchor.budget, previous.budget, anchor.id);
/* The move from the version before it, as a plain signed
amount.
Deliberately *not* run through polarity and not called
favourable or unfavourable. Favourability is a statement about
an actual measured against a budget; a budget measured against
an earlier budget is a decision, and a raised target is not
good news to the person who has to hit it. The delta is the
fact, and the reader supplies the judgement. */
const move = value == null || previousValue == null ? null : value - previousValue;
return (
<li className="flex items-baseline justify-between gap-4" key={v.id}>
<span className="text-body">
{v.name}
<span className="ml-2 text-caption text-text-tertiary">
{formatDate(v.approvedOn)}
{v.id === current?.id ? " · in force" : ""}
</span>
</span>
<span className="flex items-baseline gap-3">
<Money accounting value={value == null ? null : { amount: value, currency }} />
{/* Not coloured. This is a list of decisions, not a
series, and there is no direction a component can
assert about someone changing their own target. */}
<span className="w-32 shrink-0 text-right text-caption numeric text-text-tertiary">
{move == null || move === 0 ? (
"—"
) : (
<>
{move > 0 ? "+" : ""}
{formatMoney({ amount: move, currency }, { accounting: false })}
</>
)}
</span>
</span>
</li>
);
})}
</ul>
<p className="mt-4 text-caption text-text-tertiary">
The actual is one number. Which of these it is measured against is a choice, and the grid above
says which one it is making.
</p>
</div>
) : null}
</div>
);
}
The accounting it encodes
Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.
Also in Metrics & analysis
- MetricDefinitionEvery metric requires one. Formula with values substituted.
- NonGaapReconciliationEBITDA below net income, every adjustment named.Spec
- MrrBridgeThe movement, not a single number with a percentage.Spec
- ForecastTableProjections dashed and labeled everywhere.
- CovenantMonitorThe contractual definition, not the generic one.Spec
- KpiHealthStripProvisional marking for pre-close numbers.Spec
- RevenueTrackerRevenue by stream, with mix, growth and concentration.
- ExpenseTrackerSpend by category on three bases. Polarity derived per row from the sign convention.
- DriverModelThe inputs a forecast is built from, and what they produce.
- ScenarioCompareScenarios side by side, all measured against the base.
- PlByDimensionThe P&L cut by department, entity or product.
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
- Metrics & analysis — the family specThe four-layer specification this component was built against, before it was written.Doc
- Financial metric components: definitions and varianceThe family running, with real figures in it, in both themes and at every density.Live
- CatalogAll 179 components, specified before they are written.Doc
- DimensionA non-account axis: entity, department, projectTerm
Elsewhere in the system
Where BudgetVsActual sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.
- BudgetVsActual, runningThe metrics showcase — live, with theme, density, greyscale and reduced-motion switches.Live
- Margin & markupThey are not the same number, and confusing them is how a price list quietly loses money.Tool
- Metrics & analysisMetrics & analysis — specs for 12 React components: MetricDefinition, NonGaapReconciliation, MrrBridge, BudgetVsActual and 8 more.Doc
Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.
Told when Metrics & analysis changes
Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.