Marks consume ordinary iterables. Channels say which values from those rows control position, grouping, color, size, or identity.
TanStack Charts does not require a universal series shape. Keep the data model that best represents the problem, and let each mark consume the rows it needs.
Use a field name when the value already exists:
lineY(rows, {
x: 'date',
y: 'revenue',
z: 'region',
key: 'id',
})The field list is type-filtered. For example:
If TypeScript rejects a field name, do not cast it. Correct the row type, choose the intended field, or use a typed accessor.
Use an accessor for derived values:
dot(rows, {
x: (row) => row.revenue / row.accounts,
y: (row) => row.retained / row.accounts,
key: 'id',
})Every accessor receives:
;(datum, index, data) => valuedatum has the exact source type, index is the zero-based position, and data is the readonly materialized source array. Accessors are evaluated when the mark initializes; keep expensive cross-row transforms in application code.
The x and y channels feed the chart’s positional scale factories or instances:
barX(rows, {
x: 'revenue',
y: 'region',
})Positional values are also retained in each interaction ChartPoint:
const handleFocus = (point: ChartPoint<Row, number, string> | null) => {
if (!point) return
console.log(point.datum, point.xValue, point.yValue)
}Normal callbacks infer these types from the definition, so explicit ChartPoint annotations are usually unnecessary.
number, string, and Date are the supported chart value types. A definition can infer a union when conditional branches intentionally use different coordinate types; narrow that union with normal TypeScript control flow.
z identifies a semantic series or group:
lineY(rows, {
x: 'date',
y: 'value',
z: 'region',
key: 'id',
})For line and area marks, z partitions observations into independent geometry. For built-in marks, it also supplies a categorical value to the color resolver unless a separate color channel is available.
On bars, z does not implicitly invent grouped-bar geometry. Supply an explicit D3 groupScale when multiple bars must occupy sub-bands within the same category. See Bars and Rankings.
There are two common paths:
The default categorical palette is useful for quick distinctions. Use an explicit D3 ordinal scale when a category must always map to the same color across charts, filters, and sessions.
const segmentColor = scaleOrdinal(
['Consumer', 'Enterprise', 'Public'],
['#2563eb', '#f97316', '#10b981'],
)
const chart = defineChart({
marks: [
dot(rows, {
x: 'revenue',
y: 'retention',
z: 'segment',
key: 'id',
}),
],
x: { scale: revenueScale },
y: { scale: retentionScale },
color: {
scale: segmentColor,
legend: colorLegend({ label: 'Segment' }),
},
})This snippet directly imports scaleOrdinal from d3-scale and uses colorLegend from @tanstack/charts. Install d3-scale and @types/d3-scale as direct dependencies. Legends and Color covers continuous color, gradients, and application-wide palettes.
The r option on dot is a pixel radius unless rScale is supplied:
dot(rows, {
x: 'revenue',
y: 'retention',
r: 'accounts',
rScale: {
scale: () => scaleSqrt().range([3, 22]),
},
key: 'id',
})This direct scaleSqrt import belongs to d3-scale and requires the matching direct dependency and type package.
Keeping the scale visible makes the perceptual encoding reviewable. It also avoids silently treating a business measure as pixels.
Built-in marks infer identity in this order:
Bars use their categorical channel. Lines and areas use their independent axis. Rects and cells use their x/y interval tuple. These candidates are checked within each z group; a collision rejects the candidate and continues to the next fallback.
For common rows with a unique id, no key option is required:
barX(rows, {
id: 'product-ranking',
x: 'value',
y: 'product',
})Use an explicit key when identity lives in another field, the inferred positional value can change, or the automatic candidates are not unique:
barX(rows, {
x: 'value',
y: 'product',
key: 'productId',
})Explicit keys are string or number values and need to be unique within the mark and group. Marks without a unique automatic candidate fall back to array position and warn once per mark in development.
The mark id identifies the layer. Give conditionally rendered or reordered marks an explicit, stable id as well.
Marks ignore positional observations they cannot materialize.
Model a genuinely missing observation as null or undefined in the field type. Do not replace it with zero unless zero is the correct domain value.
For lines, a gap communicates missing data:
interface Reading {
id: string
time: Date
temperature: number | null
}
lineY(readings, {
x: 'time',
y: 'temperature',
key: 'id',
})Layering does not force one datum union:
const marks = [
rect(maintenanceWindows, {
x1: 'start',
x2: 'end',
y1: 'minimum',
y2: 'maximum',
key: 'id',
}),
lineY(readings, {
x: 'time',
y: 'temperature',
key: 'id',
}),
text(annotations, {
x: 'time',
y: 'value',
text: 'label',
key: 'id',
}),
]The definition’s interaction datum becomes the honest union of point-emitting mark data. Callbacks narrow that union using your existing discriminants or type guards.
Grouping, binning, stacking, sorting, aggregation, and spatial preparation happen in ordinary application code before mark construction:
const bins = bin().domain([minimum, maximum]).thresholds(24)(values)
const rows = bins.map((items, index) => ({
id: index,
x1: items.x0 ?? minimum,
x2: items.x1 ?? maximum,
count: items.length,
items,
}))The transform can run beside defineChart or inside the framework primitive that memoizes the complete definition. The resulting rows flow into ordinary marks. Install the granular D3 module used by the transform and its matching type package. Scales and D3 routes each responsibility to official D3 documentation without duplicating it.
Transforms and Reactivity shows the complete raw-data-to-mark path and separates application memoization from responsive layout work.
import { scaleLinear, scaleOrdinal, scaleSqrt } from 'd3-scale'
import { colorLegend, defineChart, dot } from '@tanstack/charts'
interface AccountSegment {
id: string
revenue: number
retention: number
accounts: number
segment: 'Consumer' | 'Enterprise' | 'Public'
}
const rows: readonly AccountSegment[] = [
{ id: 'a', revenue: 28, retention: 0.74, accounts: 180, segment: 'Consumer' },
{
id: 'b',
revenue: 62,
retention: 0.91,
accounts: 75,
segment: 'Enterprise',
},
{ id: 'c', revenue: 44, retention: 0.83, accounts: 120, segment: 'Public' },
{ id: 'd', revenue: 35, retention: 0.79, accounts: 240, segment: 'Consumer' },
]
const segments: readonly AccountSegment['segment'][] = [
'Consumer',
'Enterprise',
'Public',
]
const bubbleChart = defineChart({
marks: [
dot(rows, {
x: 'revenue',
y: 'retention',
z: 'segment',
r: 'accounts',
rScale: {
scale: () => scaleSqrt().range([4, 24]),
},
key: 'id',
fillOpacity: 0.72,
stroke: 'Canvas',
strokeWidth: 1,
}),
],
x: {
scale: scaleLinear().domain([0, 70]).nice(),
label: 'Revenue',
grid: true,
},
y: {
scale: scaleLinear().domain([0.65, 1]).nice(),
label: 'Retention',
format: (value) => `${Math.round(value * 100)}%`,
grid: true,
},
color: {
scale: scaleOrdinal(segments, ['#2563eb', '#f97316', '#10b981']),
legend: colorLegend({ label: 'Segment' }),
},
})This example imports d3-array and d3-scale directly. Install both modules and their matching @types packages.
For every built-in channel, see the relevant Mark Reference. For inference rules and custom datum unions, see TypeScript.