<bmx-planning-grid>
Budgeting and forecasting in the page: accounts, cost centres or products down the side, months and quarters across, and versions - Budget, Forecast, Actual, scenarios - to switch between and compare.
14 properties · 2 events · 21 methods · 6 parts
Example
Type into a month, or type a quarter's or a group's total to spread it. +10% grows a cell or a total; Ctrl+Shift+H holds cells so spreading leaves them alone.
Show markup
<bmx-planning-grid id="ex-pg" label="Operating plan 2026" scale="thousands" version="forecast" compare="budget" user="Finance"></bmx-planning-grid>
<p class="note" id="ex-pg-out" role="status" style="margin: 12px 0 0">Type into a month, or type a quarter's or a group's total to spread it. +10% grows a cell or a total; Ctrl+Shift+H holds cells so spreading leaves them alone.</p>
<script type="module">
await customElements.whenDefined('bmx-planning-grid');
const grid = document.getElementById('ex-pg');
const out = document.getElementById('ex-pg-out');
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const periods = months.map((label, i) => ({ id: `2026-${String(i + 1).padStart(2, '0')}`, label, group: `Q${Math.floor(i / 3) + 1}`, year: 'FY2026', closed: i < 9 || undefined }));
// A month's share of the year for a business that sells more before the year end.
const season = [0.07, 0.07, 0.09, 0.08, 0.08, 0.09, 0.07, 0.06, 0.09, 0.09, 0.1, 0.11];
const year = (total, shape = season, wobble = 0) => shape.map((s, i) => Math.round(total * s * (1 + wobble * Math.sin(i * 1.7))));
const flat = (monthly, growth = 0) => months.map((_, i) => Math.round(monthly * (1 + growth * i)));
const budget = {
licences: year(1800000),
subscriptions: flat(210000, 0.02),
services: year(600000, season.map(() => 1 / 12)),
hosting: flat(38000, 0.015),
support: flat(42000),
people: flat(310000, 0.01),
marketing: year(900000),
travel: flat(18000),
rent: flat(26000),
headcount: [48, 48, 50, 51, 52, 52, 54, 55, 55, 57, 58, 60],
};
// Actuals for January to September: near the budget, a little off.
const actual = Object.fromEntries(Object.entries(budget).map(([row, values]) => [row, values.map((v, i) => (i < 9 ? Math.round(v * (1 + 0.06 * Math.sin(i + row.length))) : null))]));
// The forecast for October to December, a touch more cautious than the budget.
const forecast = Object.fromEntries(Object.entries(budget).map(([row, values]) => [row, values.map((v, i) => (i >= 9 ? Math.round(v * (row === 'licences' ? 0.94 : 1.01)) : null))]));
grid.plan = {
name: 'Operating plan 2026',
currency: 'GBP',
periods,
rows: [
{ id: 'revenue', code: '4000', label: 'Revenue' },
{ id: 'licences', code: '4100', label: 'Licences', parent: 'revenue' },
{ id: 'subscriptions', code: '4200', label: 'Subscriptions', parent: 'revenue' },
{ id: 'services', code: '4300', label: 'Professional services', parent: 'revenue' },
{ id: 'cogs', code: '5000', label: 'Cost of sales', favourable: 'down' },
{ id: 'hosting', code: '5100', label: 'Hosting', parent: 'cogs', favourable: 'down' },
{ id: 'support', code: '5200', label: 'Support', parent: 'cogs', favourable: 'down' },
{ id: 'gross_margin', label: 'Gross margin', formula: 'revenue - cogs' },
{ id: 'gm_pct', label: 'Gross margin %', formula: 'IF(revenue = 0, 0, gross_margin / revenue)', format: 'percent' },
{ id: 'opex', code: '6000', label: 'Operating expenses', favourable: 'down' },
{ id: 'people', code: '6100', label: 'Salaries and benefits', parent: 'opex', favourable: 'down' },
{ id: 'marketing', code: '6200', label: 'Marketing', parent: 'opex', favourable: 'down' },
{ id: 'travel', code: '6300', label: 'Travel', parent: 'opex', favourable: 'down' },
{ id: 'rent', code: '6400', label: 'Rent and facilities', parent: 'opex', favourable: 'down', locked: true },
{ id: 'ebitda', label: 'EBITDA', formula: 'gross_margin - opex', description: 'Earnings before interest, tax, depreciation and amortisation.' },
{ id: 'ebitda_ytd', label: 'EBITDA, year to date', formula: 'YTD(ebitda)', aggregate: 'last' },
{ id: 'headcount', label: 'Headcount', format: 'integer', aggregate: 'average' },
],
versions: [
{ id: 'budget', label: 'Budget 2026', kind: 'budget' },
{ id: 'actual', label: 'Actual', kind: 'actual' },
{ id: 'forecast', label: 'Forecast (9 + 3)', kind: 'forecast', actuals: 'actual' },
],
values: { budget, actual, forecast },
profiles: [{ id: 'season', label: 'Year-end weighted', weights: season }],
status: { marketing: 'submitted' },
comments: [{ id: 'c1', version: 'forecast', row: 'licences', period: 'Q4', text: 'Two enterprise renewals moved into January.', at: '2026-10-09T10:30:00Z' }],
};
await grid.selectCell('licences', '2026-10');
grid.addEventListener('bmxChange', event => {
const n = event.detail.changes.length;
out.textContent = n ? `${n} cell${n === 1 ? '' : 's'} changed (${event.detail.reason}). The page would save event.detail.changes.` : `Plan updated (${event.detail.reason}).`;
});
</script>
<bmx-planning-grid id="plan" scale="thousands"></bmx-planning-grid>
<script>
plan.plan = {
currency: 'GBP',
periods: [{ id: '2027-01', label: 'Jan', group: 'Q1' }, ...],
rows: [
{ id: 'revenue', label: 'Revenue' },
{ id: 'licences', label: 'Licences', parent: 'revenue' },
{ id: 'costs', label: 'Costs', favourable: 'down' },
{ id: 'margin', label: 'Margin', formula: 'revenue - costs' },
],
versions: [{ id: 'budget', label: 'Budget' }, { id: 'actual', label: 'Actual', kind: 'actual' }],
values: { budget: { licences: [120000, ...] } },
};
plan.addEventListener('bmxChange', e => save(e.detail.changes));
</script>
Type into a month, or type a quarter's or a year's total - or a group's -
and it is spread over the cells below it: in proportion to what is there,
evenly, by a seasonality profile, or the same number in each. Held cells,
closed periods and signed-off rows keep their numbers. +10% grows what
is there; +=500 adds to it; 1.2k and 3m are understood.
Group rows add up their children (each adding, subtracting or left out);
formula rows are worked out per period in the library's spreadsheet
formulas with row ids as names, plus PRIOR(row) and YTD(row); quarter
and year totals follow each row's aggregate, and a ratio's total is worked
out again from the totals. A version can take its closed periods from
Actual (a rolling forecast). Compare with another version to see the
difference in every cell, coloured by whether it is good news for the row.
What-if lets a planner try changes and see their effect before applying or
discarding them. Comments, holds and sign-off are kept in the plan. Copy
and paste work with spreadsheets. Every change is handed to the page
(bmxChange, getChanges()); nothing is stored or sent anywhere.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
compare |
compare |
string |
— | Another version to compare every cell with. Two-way. |
currency |
currency |
string |
— | ISO 4217 code for currency rows. Default: the plan's, then USD. |
label |
label |
string |
— | The grid's accessible name. Default: the plan's name, or "Plan". |
locale |
locale |
string |
— | A BCP 47 locale for numbers and money. Default: the page's lang, then the reader's. |
negative |
negative |
BmxPlanNegative |
'minus' |
How negative numbers are shown. |
panel |
panel |
BmxPlanningGridPanel |
'details' |
The panel beside the grid. |
plan |
plan |
BmxPlanData | string |
{ periods: [], rows: [], versions: [], values: {} } |
The plan: periods, rows, versions, values, holds, comments and sign-off (object or JSON). Two-way. |
readonly |
readonly |
boolean |
false |
Shows the plan without letting it be changed. |
scale |
scale |
BmxPlanScale |
'units' |
Numbers shown in units, thousands or millions; typed numbers are read in the same scale. Two-way. |
spreadMethod |
spread-method |
BmxPlanSpreadMethod |
'proportional' |
How a total typed into a group row or a total column is spread. |
strings |
strings |
Partial<BmxPlanningGridStrings> | string |
— | Words to show instead of the English ones, as an object or JSON. |
totals |
totals |
BmxPlanTotals |
'both' |
Which totals are shown: group (quarter) totals, the grand total, both or none. |
user |
user |
string |
— | The name new comments are signed with. |
version |
version |
string |
— | The version shown and edited. Default: the first. Two-way. |
Events
| Event | Detail | Description |
|---|---|---|
bmxChange |
BmxPlanChangeDetail |
Fired after every change to the plan, with the whole plan and the cells that changed. |
bmxSelect |
BmxPlanSelectDetail |
Fired when the active cell moves. |
Methods
| Method | Signature | Description |
|---|---|---|
acceptChanges |
acceptChanges() => Promise<void> |
Marks the current numbers as saved: getChanges() starts again from here. |
addComment |
addComment(row: string, column: string, text: string, version?: string) => Promise<void> |
Adds a comment to a cell; column is a period id, a group's name or total. |
applyWhatIf |
applyWhatIf() => Promise<void> |
Keeps the what-if's changes as one change to the plan. |
calculate |
calculate(version?: string) => Promise<{ rows: Record<string, (number | null)[]>; errors: Record<string, (string | null)[]>; }> |
The plan worked out for a version: every row's period values, and totals by result.total(row, from, to). |
check |
check() => Promise<BmxPlanProblem[]> |
Problems with the plan's ids, parents and formulas. |
collapseAll |
collapseAll() => Promise<void> |
Shows only the top rows. |
discardWhatIf |
discardWhatIf() => Promise<void> |
Throws the what-if's changes away. |
expandAll |
expandAll() => Promise<void> |
Shows every row. |
getChanges |
getChanges() => Promise<BmxPlanChange[]> |
Every cell changed since the plan was loaded or acceptChanges() was last called. |
getCsv |
getCsv(version?: string) => Promise<string> |
The plan as CSV: every row, the periods and totals, unformatted numbers, for one version. |
getPlan |
getPlan() => Promise<BmxPlanData> |
The plan as it stands (without an unapplied what-if). |
getValue |
getValue(row: string, column: string, version?: string) => Promise<number | null> |
One number: a period's, a group's (by its name) or the year's (total). Default version: the one shown. |
hold |
hold(row: string, period: string, held?: boolean, version?: string) => Promise<void> |
Holds or releases a period cell, so spreading leaves it as it is. |
redo |
redo() => Promise<boolean> |
Redoes the last undone change. |
selectCell |
selectCell(row: string, column: string) => Promise<boolean> |
Makes a cell the active one and scrolls it into view (without moving focus); column is a period id, a group's name or total. |
setPlan |
setPlan(plan: BmxPlanData | string, options?: { keepHistory?: boolean; }) => Promise<void> |
Replaces the plan. keepHistory keeps undo. |
setStatus |
setStatus(row: string, status: BmxPlanStatus) => Promise<void> |
Sets a row's sign-off. Submitted and approved rows, and the rows below them, cannot be edited. |
setValue |
setValue(row: string, column: string, value: number | string | null, version?: string) => Promise<boolean> |
Sets a number as if typed: a period cell takes it; a group row or a total spreads it. Resolves whether anything changed. |
spread |
spread(row: string, column: string, total: number, options?: { method?: BmxPlanSpreadMethod; profile?: string; version?: string; }) => Promise<BmxPlanSpreadResult> |
Spreads a total over the cells below a row in a column (period, group name or total). |
startWhatIf |
startWhatIf() => Promise<void> |
Starts a what-if: changes are tried out and shown against the plan until applied or discarded. |
undo |
undo() => Promise<boolean> |
Undoes the last change. Resolves whether there was one. |
CSS shadow parts
| Part | Description |
|---|---|
cell |
A number cell; also cell-input, cell-total, cell-held, cell-actual, cell-error. |
details |
The cell details panel. |
dialog |
The spread dialog. |
grid |
The scrolling grid. |
row |
A row of the grid. |
toolbar |
The row of version, comparison and actions. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-planning-grid-actual |
The background of cells holding actuals. |
--bmx-planning-grid-cell-width |
The width of each number column. |
--bmx-planning-grid-favourable |
The colour of good-news differences. |
--bmx-planning-grid-head-width |
The width of the row headings column. |
--bmx-planning-grid-held |
The colour that marks a held cell. |
--bmx-planning-grid-input |
The background of cells that can be typed into. |
--bmx-planning-grid-max-height |
The tallest the grid grows before it scrolls. |
--bmx-planning-grid-total |
The background of totals and group rows. |
--bmx-planning-grid-unfavourable |
The colour of bad-news differences. |