ScenarioCompare
- Status
- Implemented
- Family
- 13 of 13
- Registry
- @lopifi/scenario-compare
npx shadcn@latest add @lopifi/theme @lopifi/scenario-compareThe four-layer spec
Scenarios in columns, measures down, every column measured against the base — the comparison is declared rather than positional, so it survives the reader reordering or hiding columns.
It will not rank the scenarios. No best-case styling, no sorting by outcome. A scenario is a set of assumptions, and presenting the largest number as the good one is how a forecast review becomes a negotiation about which column to show the board.
It will not hide a breach. A measure carrying a limit — a covenant floor, a minimum cash balance — names the scenarios that cross it in words under the grid. A downside that breaches a covenant is the only thing on the page that matters, and it is not left as a cell someone has to notice.
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/scenario-compare.tsx
- Lopifi dependencies · 15
- clipboardcncolumn-managerformat-moneyiconsinfo-tipmoneymoney.typespercentperiod-changeperiod-gridtooltipuse-column-widthsuse-grid-navuse-scroll-edges
- npm packages
- @base-ui-components/reactclass-variance-authorityclsxtailwind-merge
- Registry entry
- /r/scenario-compare.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 { periodChange } from "@/lib/ledger/period-change";
import { InfoTip } from "@/components/ledger/info-tip";
import { Money } from "@/components/ledger/money";
import { Percent } from "@/components/ledger/percent";
import type { PeriodColumn, PeriodRow } from "@/components/ledger/period-grid";
import { PeriodGrid } from "@/components/ledger/period-grid";
export interface Scenario {
id: string;
/** "Base case", "Downside", "Board plan". Named, never "Scenario 2". */
label: string;
/** What has to be true for this scenario to be the one that happens. */
premise: string;
/** One entry per measure, in the order `measures` gives them. */
values: (number | null)[];
}
export interface ScenarioMeasure {
id: string;
label: string;
/** A floor or ceiling the business cannot cross. Breaches are named. */
limit?: { kind: "min" | "max"; value: number; label: string };
/** Up is not always good — runway up is good, burn up is not. */
polarity?: "standard" | "inverted" | "neutral";
unit?: "money" | "percent" | "count" | "days" | "ratio";
}
export interface ScenarioCompareProps {
/** The scenario every other one is measured against. */
baseId: string;
className?: string;
currency: string;
measures: ScenarioMeasure[];
/** The period the comparison resolves at — "FY26", "Dec 2026". */
periodLabel: string;
scenarios: Scenario[];
storageKey?: string;
}
/**
* Scenarios side by side, every one of them measured against the base.
*
* The grid is transposed from the rest of the family — measures down,
* scenarios across — because the question is never "what does the downside do
* over time", it is "what is different about the downside". Putting the
* scenarios in columns is what lets the eye run across one row and see it.
*
* Two things this refuses to do:
*
* - **It will not rank the scenarios.** There is no "best case" styling and
* nothing is sorted by outcome. A scenario is a set of assumptions, and
* presenting the one with the largest number as the good one is how a
* forecast review becomes a negotiation about which column to show the
* board.
* - **It will not hide a breach.** A measure carrying a limit — a covenant
* floor, a minimum cash balance — says which scenarios cross it, in words,
* under the grid. A downside that breaches a covenant in month seven is the
* only thing on the page that matters, and it is not going to be left for
* someone to notice in a cell.
*/
export function ScenarioCompare({
baseId,
scenarios,
measures,
currency,
periodLabel,
storageKey,
className,
}: ScenarioCompareProps) {
const base = scenarios.find((s) => s.id === baseId) ?? scenarios[0];
const columns: PeriodColumn[] = React.useMemo(
() =>
scenarios.map((s) => ({
/* Everything is measured against the base, whatever order the reader
drags the columns into and whether or not the base is beside it. */
compare: s.id === base?.id ? undefined : base?.id,
hint: s.id === base?.id ? "base" : undefined,
id: s.id,
kind: s.id === base?.id ? "actual" : "forecast",
label: s.label,
locked: s.id === base?.id,
width: 132,
})),
[scenarios, base],
);
const rows: PeriodRow[] = React.useMemo(
() =>
measures.map((m, i) => ({
id: m.id,
label: m.label,
note: m.limit ? `${m.limit.kind === "min" ? "floor" : "ceiling"} ${m.limit.label}` : undefined,
polarity: m.polarity ?? "standard",
unit: m.unit ?? "money",
values: scenarios.map((s) => s.values[i] ?? null),
})),
[measures, scenarios],
);
/* Every breach, named. Computed from the same arrays the cells draw from. */
const breaches = React.useMemo(() => {
const out: { scenario: string; measure: string; limit: string }[] = [];
measures.forEach((m, i) => {
if (!m.limit) return;
for (const s of scenarios) {
const value = s.values[i];
if (value == null) continue;
const crossed = m.limit.kind === "min" ? value < m.limit.value : value > m.limit.value;
if (crossed) out.push({ limit: m.limit.label, measure: m.label, scenario: s.label });
}
});
return out;
}, [measures, scenarios]);
return (
<div className={cn("space-y-4", className)}>
<PeriodGrid
caption={`Scenario comparison at ${periodLabel}`}
columns={columns}
currency={currency}
footnote={
<>
{periodLabel} · every column is measured against{" "}
<span className="font-medium">{base?.label}</span>, including in the Δ and Δ% views. Scenarios are
not ranked and none of them is the plan until someone says so.
</>
}
labelHeader="Measure"
labelWidth={220}
maxHeight={420}
rows={rows}
storageKey={storageKey}
/>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{scenarios.map((s) => {
const isBase = s.id === base?.id;
/* The headline delta: the first measure, against the base. It is
the one the reader is about to ask for, and it is computed from
the same values the grid holds. */
const headline = periodChange(
s.values[0] ?? null,
base?.values[0] ?? null,
measures[0]?.polarity ?? "standard",
);
return (
<div
className={cn(
"rounded-xl border bg-surface p-4",
isBase ? "border-accent-border" : "border-border",
)}
key={s.id}
>
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-title-sm font-medium">{s.label}</h3>
{isBase ? (
<span className="rounded-xs bg-accent-subtle px-1.5 py-0.5 text-caption text-[var(--accent)]">
base
</span>
) : (
<span className="flex items-baseline gap-2">
<Percent
size="sm"
value={
headline.relative == null ? null : { kind: "relative", value: headline.relative }
}
/>
{headline.absolute != null && (measures[0]?.unit ?? "money") === "money" ? (
<Money
accounting
size="xs"
tone="secondary"
value={{ amount: headline.absolute, currency }}
/>
) : null}
</span>
)}
</div>
<p className="mt-2 text-caption text-text-secondary">{s.premise}</p>
{!isBase && measures[0] ? (
<p className="mt-2 text-caption text-text-tertiary">
{measures[0].label}, against {base?.label}.
</p>
) : null}
</div>
);
})}
</div>
{breaches.length > 0 ? (
<div className="rounded-xl border border-negative bg-surface p-4">
<h3 className="text-label font-medium">
Limits crossed
<InfoTip about="Limits crossed">
A floor or ceiling a measure is not allowed to cross — a covenant, a minimum cash balance, a
board-mandated ceiling. Named here rather than left as a cell someone has to notice.
</InfoTip>
</h3>
<ul className="mt-2 space-y-1 text-body">
{breaches.map((b) => (
<li key={`${b.scenario}-${b.measure}`}>
<span className="font-medium">{b.scenario}</span> crosses {b.limit} on {b.measure}.
</li>
))}
</ul>
</div>
) : null}
</div>
);
}
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
- BudgetVsActualPolarity-aware, materiality-filtered, versions tracked.
- 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.
- 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
Elsewhere in the system
Where ScenarioCompare 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.
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.