React components for corporate sustainability data — GHG inventories, emission intensity, energy flows, and disclosure-grade report previews.
There are excellent general component libraries, and this is not competing with any of them. Radix already solves the popover; Recharts already draws the bar. What none of them know is what the bar means.
A greenhouse gas inventory has rules that a chart library cannot enforce because it has never heard of them:
- Biogenic CO₂ is disclosed outside the scopes. It is not a fourth stack segment and it is not part of the total. A stacked bar that adds it in is wrong in a way that looks completely normal.
- Scope 2 is reported twice, location-based and market-based, and exactly one of them belongs in a headline figure. A single "Scope 2" number silently picks one.
- Scope 3 that carries no category is not zero. It has to stay visible outside the categorised breakdown rather than vanish from it.
- A figure from an unverified factor set is not a disclosure. That qualifier has to travel with the number, not sit in a footnote.
- A capped search result is not a complete one. Twenty-five of ninety-eight matches rendered as "25 results" reads exactly like a search that found twenty-five.
- A rise is bad. Emissions invert the colour convention every financial dashboard uses.
Each of those is a place where a correct-looking interface states something the data does not support. This kit encodes them in the types and in the components, so getting them wrong takes effort.
GhgAccounting calculation — GHG Protocol / ISO 14064-1, source-cited factors
↓ github.com/agirgol/carbon-accounting-dotnet
mcp-carbon-server protocol surface — MCP tools, schemas, transport
↓ github.com/agirgol/mcp-carbon-server
esg-ui-kit this repository — the visual layer
The kit's types mirror mcp-carbon-server's contracts one for one: same field names,
same string values, same nullability. A tool result renders without an adapter.
const inventory = await callTool("build_inventory", { lines, gwpSet: "Ar6" });
<ScopeBreakdownChart inventory={inventory} /> // no mapping stepThat is also why the enums look like "KilowattHour" rather than "kWh" — the server
sends .NET member names, and turning those into published symbols is this kit's job, not
something each consumer should reimplement.
ReportPreview closes the loop the other way. Its six readiness checks are the ones
mcp-carbon-server frames for a language model in its disclosure_review prompt, run
arithmetically instead — the prompt tells a model what to look for, this counts. One
standard, two implementations: one reasoning, one deterministic.
apps/demo — live — is an inventory workbench that runs the chain end to end:
browser → Next.js → MCP session → mcp-carbon-server → GhgAccounting
Type a quantity, and calculate_emissions and build_inventory run over MCP; the results
render through ScopeBreakdownChart, GhgInventoryTable, IntensityMetricCard and
ReportPreview with no adapter between them. apps/demo/lib/inventory.ts is the whole
integration.
With no MCP_CARBON_URL configured it falls back to illustrative sample data — and says
on the page which of the two it is showing, because a demo that serves canned figures
while implying a live backend is exactly the unsupported claim this kit exists to make
harder.
npm install @agirgol/esg-ui-kitimport { DataQualityBadge } from "@agirgol/esg-ui-kit";
import "@agirgol/esg-ui-kit/styles.css";
<DataQualityBadge quality="Secondary" verification="NeedsReview" />;Peer dependencies: React 18.3+ or 19.
A component library that ships a global reset is a library that edits pages it was only supposed to render inside of. Three things prevent that here:
- No preflight. Tailwind's reset is not imported. Your headings, lists and form controls are untouched.
- Prefixed utilities. Every class is
esg:*. If you run Tailwind yourself with a different scale, you cannot resize this kit's internals and it cannot resize yours. - Cascade layers. Everything sits in the
esglayer, which loses to any unlayered rule. Overriding a component is plain CSS, not a specificity fight.
There is exactly one universal selector in the output — Tailwind's
*, ::before, ::after, ::backdrop block, which seeds its --tw-* custom properties. It
declares no visual property, so it paints nothing; it is not a reset.
CI asserts all of this on every build via
scripts/assert-stylesheet-contained.mjs:
the layer wrapper, zero unprefixed classes, zero bare element selectors, and no universal
rule setting anything other than a custom property. The checker has its own failing
fixture, because a containment check that cannot fail is decoration.
Redefine the tokens on :root. They are plain custom properties, and the utilities
reference them by var() rather than baking in hex, so an override actually takes:
:root {
--esg-scope-1: #b8542a;
--esg-font-sans: "Söhne", system-ui, sans-serif;
}Dark mode follows prefers-color-scheme by default and obeys data-theme="light" | "dark"
on <html> when you want a manual switch. Both directions work; neither is required.
| Component | What it does | Status |
|---|---|---|
ScopeBreakdownChart |
Scope 1/2/3 part-to-whole, both scope 2 methods, scope 3 drill-down | ✅ shipped |
IntensityMetricCard |
Emission intensity per unit of output, with an inverted delta | ✅ shipped |
GhgInventoryTable |
Activity lines — sort, filter, CSV export, scope 2 double-count guard | ✅ shipped |
DataQualityBadge |
Data quality tier and factor-set verification status | ✅ shipped |
EmissionFactorPicker |
Catalog combobox that reports matched vs returned and resolves provenance |
✅ shipped |
EnergyFlowSankey |
Energy carrier flow, with unbalanced nodes reported rather than smoothed | ✅ shipped |
ScenarioCompareSlider |
Baseline against modelled pathways, no interpolation between them | ✅ shipped |
ReportPreview |
Disclosure preview with the six GHG Protocol readiness checks | ✅ shipped |
The pieces they are built from — Panel, ChartFrame, Figure, Delta, Legend,
StackedBar — are exported too, so a consumer's own readout can sit in the same visual
system rather than approximating it.
ChartFrame is worth calling out: its table prop is required. Every chart in this
kit has a table view because a chart cannot be written without one — which is also what
licenses the palette, since three light-mode series colours sit below 3:1 against the
surface and are legal only with a relief channel.
The look is instrument: an engineering readout, not a marketing dashboard. Chrome
recedes to hairlines and graphite ink so the only saturated colour on screen is data.
Figures use tabular numerals because a reader scans a column to compare magnitudes, and
proportional digits make 111 look narrower than 999. Monospace is reserved for
machine identifiers — factor ids, dataset ids — where character-level comparison is the
point.
Every categorical colour was validated under protanopia and deuteranopia simulation rather than chosen by eye, and the first palette proposed here failed: making Scope 3 violet collapses against Scope 2 blue in dark mode at ΔE 1.9. The runs, the failures and the reasoning are in docs/color.md — and rendered live, with the rejected pair shown side by side, under Foundations → Colour in the Storybook.
Two domain rules sit above the measurements: no emissions series is green (in an ESG interface green reads as "good", which is the opposite of what an emissions figure means), and scope hues are semantic and fixed — combustion warm, purchased electricity blue, value chain aqua — so filtering a scope never repaints the others.
The green rule is about CO₂e figures specifically. EnergyFlowSankey carries energy
rather than emissions, so a renewable carrier does take the green slot there — it is a
category, not a verdict on a quantity.
- Colour is never the only carrier. Quality is a step count, status is an icon plus a word, series get direct labels.
- Every chart has a table view —
ChartFrametakes it as a required prop. - axe runs over every component in the test suite, in both chart and table views and
in both locales, so
pnpm testfails on a violation. The checker ships a case proving it can fail. Two real defects turned up the first time it ran: a focusable badge nested inside a listbox option, and a slider whose accessible name sat on the wrong element. - Colour contrast is outside jsdom's reach — there is no layout and nothing is painted — so those rules are disabled explicitly rather than left to report false passes. Contrast is measured instead against each token's own surface, under CVD simulation, in docs/color.md.
- Both themes are selected palettes validated against their own surface, not an automatic inversion.
English and Turkish ship in the box, through a context provider with no i18n dependency — a component kit that requires i18next has made a framework choice on the consumer's behalf.
<EsgProvider locale="tr">…</EsgProvider>What is deliberately not translated: factor ids, dataset names, publishers, unit
symbols and gas formulae. A Turkish disclosure still has to cite DESNZ and kWh by the
names an auditor can look up.
pnpm install
pnpm storybook # component workshop, localhost:6006
pnpm demo # inventory workbench, localhost:3000
pnpm test
pnpm buildRequires Node 20+ and pnpm.
MIT