v2.6.0

<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

PropertyAttributeTypeDefaultDescription
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

EventDetailDescription
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

MethodSignatureDescription
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

PartDescription
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

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