Skip to content

ScenarioCompare

Scenarios side by side, all measured against the base.
Implemented
Status
Implemented
Family
13 of 13
Registry
@lopifi/scenario-compare
npx shadcn@latest add @lopifi/theme @lopifi/scenario-compare

The 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.

components/ledger/scenario-compare.tsx
"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

See also

A component is specified in one place, defined in another, and running in a third. All three, from here.

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.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.