Skip to content

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

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 a ChartConfig into 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 by GET server 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

@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: renderToString of a Chart returns 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…6 CSS variables, and the tooltip chrome reads --ts-chart-tooltip-* variables
  • Accessibility primitives are built in: ariaLabel is required, ariaDescription becomes an SVG <desc>, the SVG takes focus (tabIndex 0), arrow keys, Home/End, Enter/Space, and Escape work, 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.md says 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-scale as a direct dependency

Name check. Three package names circulate:

  • @tanstack/charts is the library
  • @tanstack/react-charts 1.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: its latest tag 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

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…5 tokens (color: "var(--chart-1)" in ChartConfig), 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 accessibilityLayer defaults to true in 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 with ResizeObserver (initialDimension defaults to { width: -1, height: -1 }); shadcn/ui’s own v3 note says to keep a height on ChartContainer “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

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

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

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

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.

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

  • 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

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/charts 1.x, imported from exact subpaths (@tanstack/charts, @tanstack/charts/scales/linear, @tanstack/charts/react, @tanstack/charts/tooltip). Never react-charts, and not @tanstack/react-charts in new apps. Add d3-scale and @types/d3-scale only for a chart that needs a time, log, or sequential-color scale (for example scaleUtc); never the d3 umbrella package
  • Read the docs shipped with the installed version. The package contains docs/ and llms.txt; the website follows unreleased main. 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 a label as props, builds the definition in useMemo, and returns <Chart>. It never fetches and never touches the QueryClient
    • Frame: ChartFrame in src/components/chart/, built on shadcn/ui Card. It renders a heading, a visible description, children for the chart, and slots for the legend and the table twin. It forwards className. Variants come from composition, not from boolean flags
    • The container passes the same rows to the chart and to its table twin, so they cannot disagree
  • 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 Date values inside its useMemo. Structural sharing then keeps data referentially 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 (initialWidth plus height, or aspectRatio). Nothing in a definition reads window, layout, the clock, or random values. Pass locale-aware formatters explicitly, with the same locale on server and client. Never switch the chart on typeof 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 its aria-label
  • Theming through our tokens: ChartFrame sets --ts-chart-1…6 to var(--chart-1)… and --ts-chart-tooltip-background, -color, -border, and -border-radius to var(--popover), var(--popover-foreground), 1px solid var(--border), and var(--radius) on its own root. Set there, they resolve against the nearest theme (light, dark, or a product brand); on :root they would freeze the root theme’s values for any nested .dark container. Text and grid keep their default currentColor. 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 that var(--chart-N) strings reach the SVG as stroke and fill. A ninth series folds into “Other” or becomes a small multiple
  • Accessibility baseline: ariaLabel on every chart, naming what it shows (“Monthly revenue, 2026”), with the conclusion in the visible heading and not in the accessible name. ariaDescription when units or period are not visible nearby. Keep keyboard on for interactive charts, keep visible focus styles, and keep respectReducedMotion at 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…5 pass; run the check
    • One series has no legend — the heading names it. Two or more series get a legend plus direct labels (colorLegend, and a text mark at line or bar ends), never a number on every point
    • Text never takes the series color. A text mark with a series (z) picks up the series color by default (we checked: fill="var(--chart-1)"), so every text mark sets fill: "currentColor". Legend labels keep their default ink; never use the label.fill: (_series, { color }) => color pattern 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
  • 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.

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.txt and 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-scale as 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.