ADR-0020 Charts
Company standard charts — TanStack Charts as server-rendered SVG behind our own thin wrappers, not shadcn/ui's Recharts-based charts, with a table twin for every chart.
- Decision
- TanStack ChartsBuild the app
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Context
Section titled “Context”Every product shows numbers: trends, comparisons, dashboards. The surrounding decisions are made, and they do not all point the same way:
- Components: shadcn/ui on Base UI, with design tokens as CSS variables (ADR-0011). shadcn/ui ships a Chart component, but it is built on Recharts (“We use Recharts under the hood”): the CLI copies
ChartContainer,ChartTooltip,ChartLegend, and aChartConfiginto the repo, and you compose Recharts’ own components inside them. So the path of least resistance is Recharts. This ADR exists because we choose otherwise, and we have to build what shadcn/ui would have given us - SSR: TanStack Start streams server-rendered React (ADR-0002). A chart should arrive as markup, not as an empty box that fills in after JavaScript measures its container
- Data: TanStack Query behind Router loaders, read with
useSuspenseQuery, fed byGETserver functions (ADR-0012); the source of truth is Postgres (ADR-0007) - Tables: TanStack Table (ADR-0018) is the standard for tabular data, which is also what a chart needs as its text alternative
- Tests: browser-mode component tests, and axe on every page an E2E journey visits (ADR-0014)
What is open: the chart library, how it takes our tokens, and who owns the accessibility baseline that no chart library provides on its own: a text alternative with the exact values.
Requirements:
- Server-rendered SVG that hydrates in place, with no fetch and no layout jump after hydration
- Themed by our CSS variables. Light and dark swap variables (ADR-0011); chart code holds no hex values
- An accessible baseline: a named chart, keyboard access to points, exact values reachable without hovering
- Bundle cost proportional to the chart, not to the library
- Room to grow: annotations, small multiples, and unusual encodings without swapping libraries mid-product
Options considered
Section titled “Options considered”Chart library
Section titled “Chart library”A. TanStack Charts
Section titled “A. TanStack Charts”@tanstack/charts is a TypeScript chart grammar. Marks (lineY, barY, dot, text, …) consume your data as it is, channels map fields to visual encodings, and scales are compact built-ins or D3 modules. The engine compiles a renderer-neutral scene that is drawn as SVG by default, or Canvas on request. The React adapter is @tanstack/charts/react. Its tagline: “A chart grammar you don’t have to outgrow.”
Pros
- Same maintainer and organization as Router, Query, Start, and Form; MIT; 1.1.0 (2026-10-09). A stable-API contract applies from 1.0: patch releases fix defects, minor releases add compatible capabilities, and removing or renaming anything needs a major
- Server-renders the complete SVG at
initialWidth, and the client adopts it. We checked:renderToStringof aChartreturns an<svg role="img" aria-roledescription="chart" aria-label="…">with grid, axes, and marks already in it - Theming is inheritance: text and grid use
currentColor, the background is transparent, series colors are--ts-chart-1…6CSS variables, and the tooltip chrome reads--ts-chart-tooltip-*variables - Accessibility primitives are built in:
ariaLabelis required,ariaDescriptionbecomes an SVG<desc>, the SVG takes focus (tabIndex0), arrow keys,Home/End,Enter/Space, andEscapework, and reduced motion is respected by default - One definition covers the standard charts and what comes after: lines, areas, bars, dots, facets, annotations, polar, treemap, Sankey, geo. D3 modules plug in where a scale needs them
- Tree-shakable (
sideEffects: false, exact subpath imports); Canvas, motion, and export stay out of the bundle until imported. TanStack’s own benchmark puts line, bar, area, and scatter charts at 43–50 kB gzip, from basic to advanced (a vendor number, not an independent measurement)
Cons
- Young: first npm publish 2026-07-29, 1.0.0 on 2026-10-03, 1.1.0 on 2026-10-09 — under three months of history, 18 days past 1.0. About 353 k downloads a week (2026-10-12 to 2026-10-18) against about 33.3 M for Recharts; 847 GitHub stars
- Its own
ACKNOWLEDGEMENTS.mdsays it “was implemented almost entirely with AI coding agents under Tanner Linsley’s direct supervision”. The evidence of quality is its test, conformance, and bundle suites and our own use, not years in production - The docs on tanstack.com follow unreleased
main; the published release is 1.1.0 - Not part of shadcn/ui: no chart components, no blocks, no registry entries. Frame, tooltip, and legend styling are ours
- A different model from Recharts’ JSX components (marks, channels, scale objects), and far fewer examples online
- Text alternatives are ours: the legend is
aria-hidden, the tooltip is “supplemental”, and the docs tell the application to provide the exact-value alternative - Compact scales cover linear, band, point, and ordinal. Time, log, and sequential color need
d3-scaleas a direct dependency
Name check. Three package names circulate:
@tanstack/chartsis the library@tanstack/react-charts1.1.0 is a thin compatibility package that depends on it; its README says “New applications use the React adapter from@tanstack/charts/react”react-charts(unscoped) is an earlier library by the same author, not this one: itslatesttag is 2.0.0-beta.7, its last publish was 3.0.0-beta.57 on 2023-11-02, and its repository,TanStack/react-charts, is archived with “No Longer Maintained” in the README
B. Recharts 3.x through shadcn/ui Charts
Section titled “B. Recharts 3.x through shadcn/ui Charts”recharts 3.10.1 (MIT, 2026-07-25), used the way shadcn/ui documents it: shadcn add chart copies the wrappers into src/components/ui/, and charts are built from BarChart, XAxis, Bar, and friends. shadcn/ui: “We do not wrap Recharts”; its chart component now uses Recharts v3.
Pros
- The path ADR-0011 already implies: copied, owned code, colored by
--chart-1…5tokens (color: "var(--chart-1)"inChartConfig), dark mode through variables, and a library of chart blocks to copy - The largest ecosystem: about 33.3 M downloads a week; most React chart answers, examples, and AI suggestions assume it
- SVG, and
accessibilityLayerdefaults totruein Recharts 3, so keyboard and screen-reader support come with it - MIT and actively released
Cons
- React-only. Responsive sizing goes through
ResponsiveContainer, which observes its parent withResizeObserver(initialDimensiondefaults to{ width: -1, height: -1 }); shadcn/ui’s own v3 note says to keep a height onChartContainer“so ResponsiveContainer can measure on first render”. The Recharts guide has no SSR chapter, so what a streamed server render emits is for us to find out - Heavier: TanStack’s own benchmark lists 97–113 kB gzip with React externalized, against 43–50 kB gzip for TanStack Charts (vendor numbers; directional only)
- It does not remove our work: the table twin, text alternatives, and the palette checks below are ours whichever library we pick
C. Apache ECharts
Section titled “C. Apache ECharts”echarts 6.1.0 (Apache-2.0, 2026-05-19).
Pros: canvas by default with an SVG renderer option, and the handbook recommends canvas for larger data (“>1k is an experience value”); server-side SVG-string rendering since 5.3.0 and a lightweight client runtime for hydration since 5.5.0; aria.show generates a description of the chart (TanStack Charts leaves that to us), and decal patterns separate series without color.
Cons: imperative echarts.init API, with React reached through a third-party wrapper such as echarts-for-react (3.0.6); themed with JavaScript palettes and registered themes, not CSS variables; aria is off by default and needs AriaComponent imported; server rendering needs explicit width and height; the heaviest bundle of the candidates (156.77–177.34 kB gzip in TanStack’s benchmark).
D. visx
Section titled “D. visx”@visx/xychart 4.0.0 (MIT, 2026-06-11). “A collection of reusable low-level visualization components” that combine D3 with React.
Pros: full control over every chart; React 18 and 19; D3 under the hood.
Cons: primitives, not charts — axes, legends, tooltips, sizing, and accessibility are assembled per chart by us; React-only; @visx/xychart also needs @react-spring/web. About 232 k downloads a week.
E. Observable Plot
Section titled “E. Observable Plot”@observablehq/plot 0.6.17 (ISC, 2025-02-14). “Focused on accelerating exploratory data analysis”; the marks-and-channels grammar TanStack Charts names as its closest API inspiration.
Pros: concise and expressive; SVG output; a mature design.
Cons: pre-1.0, and the last release is from 2025-02-14; no React component — the docs give a recipe (document option and a virtual DOM for SSR, JSDOM in Node) that we would own.
F. Chart.js
Section titled “F. Chart.js”chart.js 4.5.1 (MIT, 2025-10-13), with react-chartjs-2 5.3.1.
Pros: about 7.1 M downloads a week; canvas-first, which suits dense data; MIT.
Cons: it draws into a <canvas>, so chart content exists only after the client paints it; its docs say “the canvas content will not be accessible to screen readers” and leave names and fallback content to the application.
G. Nivo
Section titled “G. Nivo”@nivo/* 0.99.0 (MIT, 2025-05-23).
Pros: React components with SVG, HTML, and canvas output; a server-side rendering API, legends, SVG patterns, and gradients; about 846 k downloads a week for @nivo/core.
Cons: pre-1.0; the latest npm release is from 2025-05-23, although the repository still gets commits (last push 2026-07-21); React-only.
Rendering surface
Section titled “Rendering surface”- SVG by default: server-rendered, styled by CSS variables, in the accessibility tree, text stays selectable. Cost: one DOM node per mark
- Canvas per mark, only when measured: for a paint-heavy mark such as a dense scatter. It paints nothing on the server — the pixels appear after mount — and the shell is the only server output
Decision
Section titled “Decision”TanStack Charts is the company standard for charts in all new apps. We do not use shadcn/ui’s Chart component: we keep its look (tokens, Card) and replace its engine, with thin wrappers of our own.
Concretely:
- Packages:
@tanstack/charts1.x, imported from exact subpaths (@tanstack/charts,@tanstack/charts/scales/linear,@tanstack/charts/react,@tanstack/charts/tooltip). Neverreact-charts, and not@tanstack/react-chartsin new apps. Addd3-scaleand@types/d3-scaleonly for a chart that needs a time, log, or sequential-color scale (for examplescaleUtc); never thed3umbrella package - Read the docs shipped with the installed version. The package contains
docs/andllms.txt; the website follows unreleasedmain. Treat each minor upgrade like any dependency upgrade: read the release notes and run the chart component tests - One job per layer (SOLID: each has one reason to change):
- Data hook: a query-options factory plus
useSuspenseQuery, next to the feature (ADR-0012). It fetches and shapes rows, and imports nothing from the chart library - Chart component: renders. It takes
rows(only the fields it draws) and alabelas props, builds the definition inuseMemo, and returns<Chart>. It never fetches and never touches theQueryClient - Frame:
ChartFrameinsrc/components/chart/, built on shadcn/uiCard. It renders a heading, a visible description,childrenfor the chart, and slots for the legend and the table twin. It forwardsclassName. Variants come from composition, not from boolean flags - The container passes the same
rowsto the chart and to its table twin, so they cannot disagree
- Data hook: a query-options factory plus
- Data comes from Query, and is aggregated before it travels. The server function returns the rows the chart draws, bucketed or summed in the database, never thousands of raw rows. Rows are plain JSON (numbers and ISO strings), because Query’s structural sharing needs JSON-compatible data; the chart component builds
Datevalues inside itsuseMemo. Structural sharing then keepsdatareferentially stable across a refetch that changed nothing, so the definition is rebuilt only when the data really changes - SSR rules: always give the server a size (
initialWidthplusheight, oraspectRatio). Nothing in a definition readswindow, layout, the clock, or random values. Pass locale-aware formatters explicitly, with the same locale on server and client. Never switch the chart ontypeof window, and never wrap it in a client-only component unless a chart truly cannot render on the server (then reserve its height). The first app adds a Start SSR smoke test: the server response contains the chart’s<svg role="img">and itsaria-label - Theming through our tokens:
ChartFramesets--ts-chart-1…6tovar(--chart-1)… and--ts-chart-tooltip-background,-color,-border, and-border-radiustovar(--popover),var(--popover-foreground),1px solid var(--border), andvar(--radius)on its own root. Set there, they resolve against the nearest theme (light, dark, or a product brand); on:rootthey would freeze the root theme’s values for any nested.darkcontainer. Text and grid keep their defaultcurrentColor. No hex values in chart code - Series color follows the entity, not its rank. Give every multi-series chart an explicit ordinal scale with a fixed domain (
scaleOrdinal().domain([…]).range(["var(--chart-1)", …])) so filtering never repaints the surviving series. We checked thatvar(--chart-N)strings reach the SVG asstrokeandfill. A ninth series folds into “Other” or becomes a small multiple - Accessibility baseline:
ariaLabelon every chart, naming what it shows (“Monthly revenue, 2026”), with the conclusion in the visible heading and not in the accessible name.ariaDescriptionwhen units or period are not visible nearby. Keepkeyboardon for interactive charts, keep visible focus styles, and keeprespectReducedMotionat its default. A tooltip is supplemental: nothing essential lives only there. Component tests render each chart with fixed rows and assert its accessible name and that the table twin matches the rows; axe already runs on every E2E page (ADR-0014) - Chart house rules (the dataviz method our own docs site follows):
- Validate categorical palettes for color-vision deficiency, in light and dark, against the real surface: neighboring series at least ΔE 8 apart (OKLab ×100) under protanopia and deuteranopia simulation, and marks at least 3:1 against the surface unless the values are readable another way (labels or the table). We do not assume shadcn/ui’s default
--chart-1…5pass; run the check - One series has no legend — the heading names it. Two or more series get a legend plus direct labels (
colorLegend, and atextmark at line or bar ends), never a number on every point - Text never takes the series color. A
textmark with a series (z) picks up the series color by default (we checked:fill="var(--chart-1)"), so everytextmark setsfill: "currentColor". Legend labels keep their default ink; never use thelabel.fill: (_series, { color }) => colorpattern from the legend docs. Color lives on the swatch and the mark - Every chart has a table twin or an equivalent text alternative. The default is a semantic
<table>with a caption, reached by a visible control next to the chart, never hidden from everyone. A plain<table>is enough for a handful of rows; use TanStack Table (ADR-0018) when the twin needs sorting or paging. A sparkline beside a number may use that number as its alternative - Never dual y-axes. Two measures on different scales become two aligned charts or are indexed to a common base. TanStack’s own guide says dual quantitative axes “often make unrelated movement look related”
- Color is never the only signal: pair it with direct labels, position, or order
- Validate categorical palettes for color-vision deficiency, in light and dark, against the real surface: neighboring series at least ΔE 8 apart (OKLab ×100) under protanopia and deuteranopia simulation, and marks at least 3:1 against the surface unless the values are readable another way (labels or the table). We do not assume shadcn/ui’s default
- SVG by default; Canvas per mark only for a mark whose cost we measured, imported from
@tanstack/charts/canvas. Canvas pixels do not exist on the server, so a dense Canvas mark shows after hydration — keep SVG marks and axes around it - What we do not install:
recharts,shadcn add chart,react-charts,@tanstack/react-charts. A product that needs what this grammar cannot express, or data too dense for SVG and Canvas here, writes its own exception ADR (How we decide)
The deciding factors: the same maintainers and release train as Router, Query, Start, and Form; server-rendered SVG that takes our tokens by inheritance; accessibility primitives built in; and one grammar that grows with a product instead of forcing a library swap. We accept a library under three months old, the loss of shadcn/ui’s chart components and blocks, and owning the wrappers.
Consequences
Section titled “Consequences”Easier:
- Charts arrive as complete, accessible SVG in the server response and hydrate in place, with no empty frame and no measure-then-draw flash
- Theme changes are variable edits: the palette and tooltip chrome follow ADR-0011’s tokens, and dark mode needs no chart rebuild
- Data flows one way: Query → rows → chart and table twin (ADR-0012, ADR-0018). A chart component can be rendered in a test with fixed rows and no network
- Bundle cost follows the marks a chart uses; Canvas, motion, and export cost nothing until imported
- One library for the standard chart and the unusual one, and the same team as the rest of our TanStack stack
Harder:
- shadcn/ui’s Chart components, chart blocks, and examples assume Recharts. Translate them by hand; so do many Stack Overflow answers and AI suggestions. The package ships
llms.txtand its docs to help src/components/chart/is ours to build and maintain: frame, table twin, token mapping. Like the form fields in ADR-0013, it is the first candidate for the shared shadcn/ui registry (ADR-0011) once two products duplicate it- The library is young: 1.1.0, under three months on npm, a small community, and, by its own acknowledgements, a code base written almost entirely with AI agents under the maintainer’s supervision. Review each minor, read release notes, report issues upstream, and let the component tests catch regressions
- The accessibility baseline is ours to enforce in review and tests: names, descriptions, the table twin, and the color check do not come from the library
- Server and client must produce identical markup: a definition that reads the clock, the locale, or the window is a hydration mismatch. Review for it, and memoize definitions against their data
- Time axes pull in
d3-scaleas a direct dependency, with its types - The website follows
main, not the release; the docs inside the installed package are the reference
Given up: shadcn/ui’s Chart components and blocks, Recharts’ ecosystem (about 33.3 M weekly downloads against about 353 k), and the track record of a library with years in production. Accepted — server-rendered SVG themed by our own tokens, built-in accessibility primitives, and one grammar that grows outweigh them for a company default. Write a superseding ADR if the library stalls, if a 1.x release breaks its compatibility contract, or if shadcn/ui’s charts move off Recharts.

