TanStack Charts follows the grammar-of-graphics tradition established by Leland Wilkinson and developed through projects such as ggplot2, Vega-Lite, and Observable Plot. Observable Plot is the closest API influence for mark-local data, channels, and layered composition.
The grammar describes what visual encodings mean and lets the runtime decide how to lay them out and render them. A chart is not a special-purpose component with a fixed series model. It is a composition of:
The result is one ChartSpec compiled into a renderer-neutral scene.
import { scaleLinear } from 'd3-scale'
import { defineChart, lineY } from '@tanstack/charts'
const values = [4, 9, 7, 12]
const chart = defineChart({
marks: [lineY(values)],
x: { scale: scaleLinear },
y: { scale: scaleLinear, nice: true },
})lineY(values) uses the array index for x and the numeric datum for y. Supplying explicit channels makes the same grammar work with application rows.
Because this example imports d3-scale directly, add d3-scale and @types/d3-scale as direct dependencies. Scales and D3 explains why scales remain explicit.
Each mark receives its own iterable:
const marks = [
areaY(forecastRows, {
x: 'date',
y1: 'low',
y2: 'high',
key: 'id',
}),
lineY(actualRows, {
x: 'date',
y: 'value',
key: 'id',
}),
ruleY([target]),
]The arrays may have different lengths and datum types. There is no required { series: [...] } wrapper and no requirement to reshape unrelated layers into one table. This keeps simple charts simple and lets custom compositions use the data model that naturally represents each layer.
If a transform creates new rows, run that transform before the mark. Memoize expensive derived rows through application or framework reactivity. See Chart Definitions.
A mark turns data and channel values into scene nodes and interaction points:
lineY(rows, {
x: 'date',
y: 'revenue',
z: 'region',
key: 'id',
})Choose a mark for the analytical task, then layer other marks to add context. Marks and Layering describes the built-in families.
A channel is usually a compatible field name:
dot(rows, {
x: 'revenue',
y: 'retention',
z: 'segment',
r: 'accounts',
key: 'id',
})It can also be an accessor when the value is derived:
dot(rows, {
x: (row) => row.revenue / row.accounts,
y: 'retention',
key: 'id',
})Accessors receive (datum, index, data) and remain fully typed. Field channels are filtered by the value type the mark accepts, so TypeScript rejects a date field where a numeric bar length is required.
Channels describe mappings. Constant appearance options such as stroke: '#2563eb' or fillOpacity: 0.2 describe a fixed style. The distinction keeps semantic encodings visible in source.
Read Data and Channels for missing values, accessors, keys, grouping, color, and radius.
Pass a D3 factory when its domain should follow the mark channels:
const axes = {
x: {
scale: scaleUtc,
nice: true,
label: 'Month',
},
y: {
scale: scaleLinear,
nice: true,
label: 'Revenue',
grid: true,
},
}The marks supply the domain, the factory supplies the mapping, and TanStack Charts supplies the responsive range. Pass a configured scale instance when the application owns a fixed domain.
Axis guide options live next to their scale:
const y = {
scale: revenueScale,
label: 'Monthly revenue',
format: (value: number) => `$${Math.round(value / 1_000)}k`,
ticks: 5,
grid: true,
}The scale maps values. The guide makes that mapping legible. ticks, format, label, grid, reverse, tickRotate, and labelOffset are presentation controls for the axis; they do not replace scale semantics.
Omitted margins are measured from the actual guides. See Layout, Axes, and Coordinates.
Marks render in array order. Put context behind the primary data and annotations above it:
import { scaleBand, scaleLinear } from 'd3-scale'
import { curveMonotoneX } from 'd3-shape'
import {
areaY,
barY,
d3Curve,
defineChart,
dot,
lineY,
ruleY,
} from '@tanstack/charts'
interface Quarter {
id: string
quarter: string
revenue: number
forecast: number
}
const quarters: readonly Quarter[] = [
{ id: 'q1', quarter: 'Q1', revenue: 38, forecast: 35 },
{ id: 'q2', quarter: 'Q2', revenue: 45, forecast: 43 },
{ id: 'q3', quarter: 'Q3', revenue: 41, forecast: 46 },
{ id: 'q4', quarter: 'Q4', revenue: 54, forecast: 51 },
]
const composedChart = defineChart({
marks: [
ruleY([40], { stroke: '#94a3b8', strokeOpacity: 0.65 }),
areaY(quarters, {
x: 'quarter',
y1: 'forecast',
y2: 'revenue',
key: 'id',
fill: '#60a5fa',
fillOpacity: 0.16,
}),
barY(quarters, {
x: 'quarter',
y: 'revenue',
key: 'id',
fill: '#bfdbfe',
inset: 12,
}),
lineY(quarters, {
x: 'quarter',
y: 'forecast',
key: 'id',
stroke: '#f97316',
curve: d3Curve(curveMonotoneX),
}),
dot(quarters, {
x: 'quarter',
y: 'revenue',
key: 'id',
r: 4,
fill: '#2563eb',
}),
],
x: {
scale: () => scaleBand<string>().padding(0.12),
label: 'Quarter',
},
y: {
scale: scaleLinear().domain([0, 60]).nice(),
label: 'Revenue',
grid: true,
},
})This source imports d3-scale and d3-shape directly, so add those modules and their matching @types packages as direct dependencies. d3Curve is the small bridge from a D3 curve factory to the mark curve contract.
defineChart preserves the relationship between datum types, channel values, configured scales, axes, scenes, and interaction callbacks.
The definition is also the application memoization boundary. Keep reusable definitions at module scope. In a component, memoize the complete definition against the application values it captures.
The grammar does not contain DOM or framework lifecycle code. It compiles to a keyed ChartScene containing:
The built-in SVG renderer, DOM and framework hosts, static exporter, and custom renderers all consume that same result.
Continue with Chart Definitions, or start from a task in Choosing a Chart.