<bmx-risk-matrix>
A risk register on a likelihood-by-impact heat map, for governance, risk and compliance, internal audit, programme offices and insurers.
11 properties · 3 events · 16 methods · 5 parts
Example
Sixteen risks on a 5 × 5 grid with an appetite of 9. Drag a risk to re-score it, or choose one and use Shift with the arrow keys.
Show markup
<bmx-risk-matrix id="ex-risks" label="Enterprise risk register, Q3" today="2026-10-13" arrows="treatment"></bmx-risk-matrix>
<p class="note" id="ex-risks-out" role="status">Sixteen risks on a 5 × 5 grid with an appetite of 9. Drag a risk to re-score it, or choose one and use Shift with the arrow keys.</p>
<div class="row" style="gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-block: 0.5rem 1.5rem">
<button type="button" id="ex-risks-check">What needs attention?</button>
<button type="button" id="ex-risks-svg">Show the heat map as SVG</button>
</div>
<div id="ex-risks-art"></div>
<script type="module">
await customElements.whenDefined('bmx-risk-matrix');
const matrix = document.getElementById('ex-risks');
const out = document.getElementById('ex-risks-out');
const r = (id, title, category, owner, inherent, residual, extra = {}) => ({
id, title, category, owner,
inherent: { likelihood: inherent[0], impact: inherent[1] },
residual: { likelihood: residual[0], impact: residual[1] },
...extra,
});
matrix.model = {
appetite: 9,
bands: [
{ id: 'low', label: 'Low', min: 1, max: 4, action: 'Manage by routine procedures' },
{ id: 'medium', label: 'Medium', min: 5, max: 9, action: 'Owner monitors; review quarterly' },
{ id: 'high', label: 'High', min: 10, max: 16, action: 'Treatment plan; executive attention' },
{ id: 'critical', label: 'Critical', min: 17, max: 25, action: 'Escalate to the board now' },
],
risks: [
r('OP-01', 'Single supplier for packaging fails', 'Operational', 'Head of Procurement', [4, 4], [2, 4], { target: { likelihood: 1, impact: 3 }, previous: { likelihood: 3, impact: 4 }, status: 'mitigating', controls: ['Second supplier qualified for 40% of volume', 'Eight weeks of safety stock'], reviewBy: '2026-12-01' }),
r('OP-02', 'Warehouse management system outage at peak', 'Operational', 'COO', [3, 5], [2, 4], { previous: { likelihood: 2, impact: 4 }, controls: ['Hot standby in a second region', 'Paper picking fallback rehearsed twice a year'] }),
r('IT-01', 'Ransomware encrypts file servers', 'Technology', 'CISO', [4, 5], [3, 4], { target: { likelihood: 2, impact: 3 }, previous: { likelihood: 2, impact: 4 }, status: 'mitigating', description: 'Phishing tests show click rates rising after the merger.', controls: ['Immutable backups, tested monthly', 'EDR on every endpoint'], reviewBy: '2026-09-30' }),
r('IT-02', 'Customer data exposed by a misconfigured bucket', 'Technology', 'CISO', [3, 5], [2, 5], { target: { likelihood: 1, impact: 5 }, previous: { likelihood: 2, impact: 5 }, controls: ['Policy-as-code checks in every pipeline'] }),
r('IT-03', 'End-of-life ERP version unsupported', 'Technology', 'CIO', [5, 3], [4, 3], { previous: { likelihood: 4, impact: 3 }, status: 'mitigating', reviewBy: '2026-11-15' }),
r('FN-01', 'Euro exposure on supplier contracts', 'Financial', 'CFO', [4, 3], [2, 3], { previous: { likelihood: 3, impact: 3 }, controls: ['Twelve-month rolling hedge at 75%'] }),
r('FN-02', 'Largest customer moves to quarterly payment', 'Financial', 'CFO', [3, 4], [3, 3], { previous: { likelihood: 3, impact: 3 } }),
r('FN-03', 'Covenant breach on the revolving facility', 'Financial', 'CFO', [2, 5], [1, 5], { status: 'accepted', previous: { likelihood: 1, impact: 5 } }),
r('PE-01', 'Loss of two senior engineers in one quarter', 'People', 'CHRO', [3, 3], [2, 3], { previous: { likelihood: 2, impact: 3 } }),
r('PE-02', 'Forklift injury on the night shift', 'Health and safety', 'Site Director', [3, 5], [2, 4], { target: { likelihood: 1, impact: 4 }, previous: { likelihood: 2, impact: 4 }, controls: ['Pedestrian walkways', 'Speed limiters on every truck'] }),
r('PE-03', 'Agency staff not inducted before first shift', 'Health and safety', 'Site Director', [4, 3], [3, 3], { previous: { likelihood: 2, impact: 3 }, reviewBy: '2026-10-01' }),
r('CO-01', 'New packaging waste levy underestimated', 'Compliance', 'General Counsel', [4, 3], [3, 3], { previous: { likelihood: 3, impact: 3 } }),
r('CO-02', 'Modern slavery statement not evidenced', 'Compliance', 'General Counsel', [2, 4], [1, 4], { previous: { likelihood: 1, impact: 4 } }),
r('ST-01', 'Competitor launches same-day delivery in our region', 'Strategic', 'CEO', [3, 4], [3, 4], { target: { likelihood: 3, impact: 2 }, previous: { likelihood: 3, impact: 3 } }),
r('ST-02', 'Integration of the acquired business slips a year', 'Strategic', 'Integration Director', [4, 4], [3, 4], { previous: { likelihood: 3, impact: 4 }, status: 'mitigating' }),
r('EN-01', 'Flooding at the river-side site', 'Environmental', '', [2, 5], [2, 4], { controls: ['Flood barriers', 'Stock raised off the floor'] }),
],
};
matrix.addEventListener('bmxChange', e => {
if (e.detail.reason === 'move') out.textContent = `${e.detail.riskId} re-scored (${e.detail.view}): likelihood ${e.detail.from.likelihood} → ${e.detail.to.likelihood}, impact ${e.detail.from.impact} → ${e.detail.to.impact}.`;
else out.textContent = `Register changed: ${e.detail.reason}.`;
});
matrix.addEventListener('bmxCellSelect', e => (out.textContent = `Score ${e.detail.score} (${e.detail.band.label}): ${e.detail.risks.length} risk${e.detail.risks.length === 1 ? '' : 's'}.`));
document.getElementById('ex-risks-check').addEventListener('click', async () => {
const problems = await matrix.check();
out.textContent = problems.length ? problems.map(p => `${p.riskId}: ${p.message}`).join(' ') : 'Nothing needs attention.';
});
document.getElementById('ex-risks-svg').addEventListener('click', async () => {
const art = document.getElementById('ex-risks-art');
const svg = await matrix.exportSvg('Residual risk, Q3');
art.replaceChildren(new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement);
});
</script>
WHAT IT DOES
- The grid is 3 x 3 to 7 x 7 with the organisation's own words for each step, scored likelihood x impact, likelihood + impact, or by a matrix of the organisation's own ratings, and coloured by score bands with what each band calls for.
- Each risk has an inherent score (before controls), a residual one (after them), a target, and the residual score at the last review. Switch views, draw arrows from inherent to residual to target, or from the last review to now, and see which risks moved and which way.
- The appetite - the highest score the organisation accepts - is drawn as a line through the grid, and counted.
- Re-score by dragging a risk to another cell, with the keyboard, or in the details panel. Undo and redo.
- The register beside it is filtered by the cell or band chosen, by search, and sorted by any column; CSV, an SVG of the heat map, and JSON out.
The register is plain JSON on the element (model, bmxChange); nothing
is sent anywhere.
Properties
| Property | Attribute | Type | Default | Description |
|---|---|---|---|---|
arrows |
arrows |
BmxRiskArrows |
'none' |
Arrows on the grid: none, treatment (inherent to residual to target) or movement (since the last review). |
display |
display |
'risks' | 'counts' |
'risks' |
Cells show the risks themselves, or how many there are. |
fileName |
file-name |
string |
'risk-register' |
The name exported files are saved under, without the extension. |
label |
label |
string |
— | The element's accessible name. |
model |
model |
Partial<BmxRiskModel> | string |
— | The register, its scales, bands and appetite (an object, or its JSON). Kept up to date as risks are re-scored. |
readonly |
readonly |
boolean |
false |
Look but do not change. |
selected |
selected |
string |
— | The chosen risk's id. |
showRegister |
show-register |
boolean |
true |
Show the register under the grid. |
strings |
strings |
Partial<BmxRiskMatrixStrings> | string |
— | Replacements for any of the wording (an object, or its JSON). |
today |
today |
string |
— | Today, as YYYY-MM-DD, for overdue reviews. Unset: the computer's date. |
view |
view |
Exclude<BmxRiskView, 'previous'> |
'residual' |
Which scores the grid shows. |
Events
| Event | Detail | Description |
|---|---|---|
bmxCellSelect |
BmxRiskCellDetail |
Fired when a cell is chosen (Enter, or a click on it). |
bmxChange |
BmxRiskChangeDetail |
Fired when risks are re-scored or edited, a review is recorded, or a change is undone. |
bmxSelect |
BmxRiskSelectDetail |
Fired when another risk is chosen. |
Methods
| Method | Signature | Description |
|---|---|---|
check |
check() => Promise<BmxRiskProblem[]> |
What needs attention: overdue reviews, risks above appetite, scores that went the wrong way, no owner. |
download |
download(format?: "csv" | "svg" | "json") => Promise<void> |
Saves the register or the heat map as a file. |
exportCsv |
exportCsv(onlyShown?: boolean) => Promise<string> |
The register as CSV (only the risks shown, when asked). |
exportJson |
exportJson() => Promise<string> |
The register and its scales as JSON. |
exportSvg |
exportSvg(title?: string) => Promise<string> |
The heat map as a stand-alone SVG document. |
getModel |
getModel() => Promise<BmxRiskModel> |
The register as it is now. |
getRisk |
getRisk(id: string) => Promise<BmxRisk | null> |
One risk, by id. |
getSummary |
getSummary(view?: BmxRiskView, onlyShown?: boolean) => Promise<BmxRiskSummary> |
The figures for the risks shown (or all of them) in a view. |
markReviewed |
markReviewed() => Promise<void> |
Records a review: every risk's residual score becomes its last-review score. |
moveRisk |
moveRisk(id: string, likelihood: number, impact: number, view?: Exclude<BmxRiskView, "previous">) => Promise<void> |
Moves a risk to a cell in a view (the current one when not given). |
redo |
redo() => Promise<void> |
Puts back what undo took. |
selectCell |
selectCell(likelihood: number | null, impact?: number) => Promise<void> |
Chooses a cell and shows its risks in the register (or clears that, with null). |
selectRisk |
selectRisk(id: string | null) => Promise<void> |
Chooses a risk (or none). |
setModel |
setModel(model: Partial<BmxRiskModel> | string, options?: { keepHistory?: boolean; }) => Promise<void> |
Replaces the register. Undo goes back to what was there, unless keepHistory is false. |
undo |
undo() => Promise<void> |
Takes back the last change. |
updateRisk |
updateRisk(id: string, patch: Partial<BmxRisk>) => Promise<void> |
Changes a risk's fields (not its id). |
CSS shadow parts
| Part | Description |
|---|---|
matrix |
The heat map. |
panel |
The chosen risk's details. |
register |
The register table. |
summary |
The band legend and figures. |
toolbar |
The tools along the top. |
CSS custom properties
| Property | Description |
|---|---|
--bmx-risk-matrix-appetite |
The appetite line's colour. |
--bmx-risk-matrix-band-1 |
The lowest band's colour (and -2 to -6 for the next ones up), unless a band names its own. |
--bmx-risk-matrix-cell-ratio |
A cell's width over its height. |
--bmx-risk-matrix-panel-width |
The details panel's width beside the grid. |
--bmx-risk-matrix-radius |
Corner radius of the frame. |