TanStack
TanStack

Charts

alpha

A chart grammar you don't have to outgrow.

A typed, tree-shakable chart grammar for SVG and Canvas. Compose marks, views, scales, transforms, interactions, and motion with compact primitives or D3-compatible inputs.

Docs

Seattle weather with three y axes

composition

Flare analytics sunburst

hierarchy

shadcn dashboard

application

Your next chart, already working.

A lot of chart. Not a lot of bundle.

TanStack Charts starts at 40 kB minified and gzip compressed in the current matched suite, with its SVG renderer, scales, axes, and styles included.

Bundle size snapshotAugust 2026 · rendered with TanStack Charts
A static August 2026 snapshot of minified and gzip-compressed browser bundle sizes. TanStack Charts: 40 kilobytes; uPlot: 22 kilobytes; Chart.js: 46 kilobytes; visx: 49 kilobytes; Lightweight Charts: 60 kilobytes; Observable Plot: 85 kilobytes; Vega-Lite: 87 kilobytes; D3: 90 kilobytes; Recharts: 97 kilobytes; Highcharts: 100 kilobytes; Victory: 105 kilobytes; Nivo: 143 kilobytes; Apache ECharts: 157 kilobytes; ApexCharts: 164 kilobytes; Plotly.js partial: 250 kilobytes; AG Charts: 367 kilobytes.
See the full bundle comparison

Types stay connected to the source row.

Fields, datum types, inferred domains and keys, tooltips, and focus callbacks all trace back to the data you passed in. Every example compiles under strict TypeScript, and invalid definitions fail before they reach the browser.

account-health.tsx
import { scaleLinear } from '@tanstack/charts/scales/linear'
import { defineChart, dot } from '@tanstack/charts'
import { tooltip } from '@tanstack/charts/tooltip'
import { Chart } from '@tanstack/charts/react'
import { scaleSqrt } from 'd3-scale'

const accountHealth = defineChart({
  marks: [
    dot(accounts, {
      x: 'monthlyRevenue',
      y: 'retention',
      r: 'seats',
      rScale: {
        scale: () => scaleSqrt().range([4, 22]),
      },
      z: 'segment',
      key: 'id',
    }),
  ],
  scales: {
    x: {
      scale: scaleLinear,
      axis: { label: 'Monthly revenue ($k)' },
    },
    y: {
      scale: scaleLinear,
      axis: {
        label: '90-day retention',
        ticks: { format: (value) => percent.format(value) },
      },
    },
  },
  tooltip,
})

export function AccountHealthChart({
  onFocus,
}: {
  onFocus: (account: Account | null) => void
}) {
  return (
    <Chart
      definition={accountHealth}
      ariaLabel="Account health by revenue, retention, segment, and seats"
      onFocusChange={(point) => onFocus(point?.datum ?? null)}
    />
  )
}

Account health

Illustrative account dataset

Which high-revenue accounts need retention attention?

EnterpriseGrowthSMBBubble size = seats
Illustrative account data. Horizontal position shows monthly revenue in thousands of dollars, vertical position shows 90-day retention, color distinguishes SMB, Growth, and Enterprise segments, and bubble area represents seats.

SVG by default. Canvas where it earns its weight.

Move one paint-heavy mark to Canvas while axes, labels, focus, tooltips, responsive layout, hydration, and export keep working through one definition. Canvas and motion stay out of the default bundle until you import them.

Rendering and export reference

One definition, three surface choices

mixed-surface.ts
import { canvasChartRenderer } from '@tanstack/charts/canvas'
import { defineChart, dot, lineY } from '@tanstack/charts'

const activity = defineChart({
  marks: [
    lineY(summary, {
      x: 'time',
      y: 'average',
    }),
    dot(events, {
      x: 'time',
      y: 'latency',
      renderer: canvasChartRenderer,
    }),
  ],
  scales,
  tooltip,
})

Default SVG

Nothing

Axes, labels, marks, and focus render as accessible SVG. Tooltips use an accessible HTML live region.

Mixed SVG + Canvas

One mark option

Only the dense mark paints to Canvas. SVG guides and shared interaction stay in place.

Full Canvas

One adapter import

The whole chart paints to Canvas while the definition and host callbacks stay the same.

The same definition also feeds the web adapters, vanilla DOM, static output, and the experimental React Native adapter.

Make it look like your product.

Same data, scales, and interactions. CSS variables, themes, mark props, and custom tooltip content make the chart belong to your product.

Editorial

Product

Terminal

Monokai

Add ranges, goals, and events without switching APIs.

The area, goal rule, activation line, release points, and labels each use the rows and channels they need, then share one coordinate system. The same definition mixes a compact linear scale with D3's UTC scale and monotone curve.

Weekly activation rate

Illustrative product telemetry

78%

above 70% goal

ActivationExpected rangeGoalReleases
Illustrative weekly activation data from January through May 2026. The actual rate rises from 48 to 78 percent, compared with an expected range and a 70 percent goal. Onboarding v2 and Invite flow are marked as release events.
activation-chart.ts
import { scaleLinear } from '@tanstack/charts/scales/linear'
import { scaleUtc } from 'd3-scale'
import { curveMonotoneX } from 'd3-shape'
import {
  areaY,
  d3Curve,
  defineChart,
  dot,
  lineY,
  ruleY,
  text,
} from '@tanstack/charts'

import { releases, weeks } from './activation-data'
import { activationTheme } from './activation-theme'

const monthDay = new Intl.DateTimeFormat('en-US', {
  day: 'numeric',
  month: 'short',
  timeZone: 'UTC',
})

export const activationChart = defineChart({
  marks: [
    areaY(weeks, {
      id: 'activation-range',
      x: 'date',
      y1: 'expectedLow',
      y2: 'expectedHigh',
      key: 'id',
      fill: 'var(--activation-range)',
      fillOpacity: 0.22,
      curve: d3Curve(curveMonotoneX),
    }),
    ruleY([70], {
      id: 'activation-goal',
      stroke: 'var(--activation-goal)',
      strokeOpacity: 0.95,
      strokeWidth: 2,
      strokeDasharray: '7 7',
    }),
    lineY(weeks, {
      id: 'activation-line',
      x: 'date',
      y: 'activation',
      key: 'id',
      stroke: 'var(--activation-line)',
      strokeWidth: 4.25,
      points: true,
      curve: d3Curve(curveMonotoneX),
    }),
    dot(releases, {
      id: 'activation-events',
      x: 'date',
      y: 'activation',
      key: 'id',
      r: 6,
      fill: 'var(--activation-bg)',
      stroke: 'var(--activation-release)',
      strokeWidth: 3,
    }),
    text(releases, {
      id: 'activation-event-labels',
      x: 'date',
      y: 'activation',
      text: 'label',
      key: 'id',
      fill: 'var(--activation-foreground)',
      fontSize: 12,
      fontWeight: 650,
      dy: -21,
    }),
  ],
  scales: {
    x: {
      scale: scaleUtc().domain([weeks[0]!.date, weeks.at(-1)!.date]),
      axis: {
        label: 'Week ending',
        ticks: { format: (value) => monthDay.format(value) },
      },
      grid: false,
    },
    y: {
      scale: scaleLinear().domain([40, 82]),
      axis: {
        label: 'Activation rate (%)',
        ticks: { count: 5, format: (value) => `${Math.round(value)}%` },
      },
      grid: true,
    },
  },
  theme: activationTheme,
})

TanStack Charts builds on Leland Wilkinson's grammar of graphics and the work of ggplot2, Vega-Lite, and Observable Plot. Its marks-and-channels API is most directly inspired by Observable Plot, but the runtime is an independent implementation.

Partners

Gold
Cloudflare
Railway
Lovable
Netlify
CodeRabbit
Silver
WorkOS
Clerk
OpenRouter
AG Grid
SerpApi
Bronze
Sentry
Prisma
Unkey
Electric
OSS Sponsors

Sponsors get special perks like private discord channels, priority issue requests, and direct support!