Skip to main content

Mantle Chart Root Mode Migration Guide

@ngrok/mantle's chart Roots (LineChart.Root, BarChart.Root, AreaChart.Root, and ScatterPlot.Root) take a mode prop in place of the decorative boolean. mode has three values: "interactive" (the default, the chart as it always was), "static" (new: a named image with a description, no Tab stop, no hover, no tooltip, no table), and "decorative" (the old decorative behavior). This guide moves a codebase from the boolean to the prop and names the charts that belong in the new mode.

TL;DR: Replace decorative with mode="decorative" on every chart Root. Then set mode="static" on every chart a reader glances at while its values sit beside it as text, such as a sparkline in a table row: drop its Tooltip part, keep its name, and leave animate off.

Codemod

From a mantle checkout:

pnpm -w run migrate 0009 <path>

In a project that installs @ngrok/mantle:

npx --yes --package=@ast-grep/cli@0.51.0 ast-grep scan --rule node_modules/@ngrok/mantle/dist/migrations/0009-chart-root-mode.yml --update-all <path>

The rules apply step 2. On the four chart Roots, decorative becomes mode="decorative", and decorative={flag} becomes mode={flag ? "decorative" : "interactive"}. Steps 3 to 5 stay manual, because whether a chart is a sparkline is a design call that no rule can read off the JSX. The rules read a part through its import from @ngrok/mantle/<chart>, under its own name or an alias. A part that reaches the file through a local re-export or a destructured namespace stays manual. A function that binds a chart's name to a parameter or a local shadows the import, so the rules leave that function alone. Pass --dry-run to print the rewrites first, and run your formatter after the rewrite.


You are an agent migrating a codebase. Do this:

  1. Find every chart Root with the decorative prop: LineChart.Root, BarChart.Root, AreaChart.Root, and ScatterPlot.Root from @ngrok/mantle. Search for decorative next to one of those names, and for a renamed import of the same part.
  2. For each, replace decorative with mode="decorative". When the value is dynamic (decorative={isPlaceholder}), write mode={isPlaceholder ? "decorative" : "interactive"}. Nothing else changes: a decorative chart still forbids aria-label, aria-labelledby, activeIndex, onActiveIndexChange, and onDatumActivate.
  3. Find every chart Root that is a sparkline, a trend in a card, or a chart in a print view: a Root with a className that sets a small fixed size (h-6 w-28), a Root inside a DataTable.Cell, or a Root with no axes and one series beside a number. For each, add mode="static" and delete its Tooltip part (<BarChart.Tooltip /> and the like); a static chart shows no readout. Keep the aria-label or aria-labelledby: a static chart requires a name. Delete activeIndex, onActiveIndexChange, and onDatumActivate from it: a static chart forbids them.
  4. Leave animate off a static chart unless the product wants the reveal. animate defaults to false in static mode and true in the other two.
  5. Find a test that reaches a sparkline through getByRole("application", …) or user.tab(). A static chart is getByRole("img", { name }), and it is not a Tab stop.
  6. Leave every other chart Root alone. The default mode is unchanged.
  7. After editing, run the project's formatter, linter, type checker, and tests.

Why a mode? Two booleans could not say "noninteractive but not decorative". A sparkline beside a total still tells a reader when activity happened and how it varied, so it keeps a name and a description. It has no Tab stop, because one hundred sparklines in a table as one hundred Tab stops is a focus order no reader wants, and no hover readout, because a value only a pointer can reach fails WCAG 1.4.13. mode names the three contracts, and the type forbids the props each one has no use for.


Before → after

A placeholder behind an empty state

// BEFORE<BarChart.Root data={placeholderUsage} xKey="day" decorative>	<BarChart.Bar dataKey="value" /></BarChart.Root>
// AFTER<BarChart.Root data={placeholderUsage} xKey="day" mode="decorative">	<BarChart.Bar dataKey="value" /></BarChart.Root>

A sparkline in a table cell

// BEFORE: a Tab stop and a readout in every row<BarChart.Root	data={service.requests}	xKey="day"	aria-label={`Requests for ${service.name}, last 30 days`}	className="aspect-auto h-6 w-28">	<BarChart.Bar dataKey="count" label="Requests" />	<BarChart.Tooltip /></BarChart.Root>
// AFTER: a named image with a description; the total beside it carries the value<BarChart.Root	data={service.requests}	xKey="day"	mode="static"	aria-label={`Requests for ${service.name}, last 30 days`}	className="aspect-auto h-6 w-28">	<BarChart.Bar dataKey="count" label="Requests" /></BarChart.Root>

A test that reaches a sparkline

// BEFOREconst chart = screen.getByRole("application", { name: "Requests for edge-router, last 30 days" });
// AFTERconst chart = screen.getByRole("img", { name: "Requests for edge-router, last 30 days" });

What it buys on a list page

The chart benchmark page in the docs site (/bench/charts) renders 100 rows with two bar sparklines each in a DataTable, 30 bars per chart, with the mount animation off. The same production build of that page ran against the previous mantle and against this one. Chromium 153 headless, DPR 2, CPU 1×, medians of 3 runs.

metricbeforeafter
mount, from chart code loaded to painted1,328 ms117 ms
main-thread time in the mount window1,446 ms99 ms
style recalculation1,154 ms28 ms
Total Blocking Time1,145 ms0 ms
longest task727 ms29 ms
DOM nodes23,2043,300
hover sweep, main-thread681 ms334 ms
the same table with no charts68 ms66 ms

The charts' share of the mount went from about 1,260 ms to about 35 ms, 0.17 ms per sparkline. Most of the gain is the dormant mount, which every chart gets on upgrade. mode="static" adds the rest: no overlay, no keyboard sentence, and no tooltip registration per cell, 700 fewer DOM nodes, and a hover sweep that costs 40% less, because no cell runs hover handling.

What each mode renders

modeNamed elementTab stopHover and tooltipKeyboard point cursorData table on focusLive regionSeries colorsanimate default
"interactive" (default)The overlay, role="application"YesYesYesYesYesThe palettetrue
"static"The plot, role="img", describedNoNoNoNoNoThe palettefalse
"decorative"None; the Root is aria-hiddenNoNoNoNoNoOne neutral filltrue

The description (data-slot="<kind>-description") renders in interactive and static mode. In static mode it ends after the series labels; the keyboard instructions belong to the overlay a static chart does not have.

Type-level contract

// Interactive: a name is required, the interaction props are allowed.<LineChart.Root data={rows} xKey="time" aria-label="Latency" onDatumActivate={open}>…</LineChart.Root> // Static: a name is required, the interaction props do not compile.<LineChart.Root data={rows} xKey="time" mode="static" aria-label="Latency">…</LineChart.Root> // Decorative: neither a name nor an interaction prop compiles.<LineChart.Root data={rows} xKey="time" mode="decorative">…</LineChart.Root>