A mark turns data and channel values into renderer-neutral scene nodes. Marks are small, composable units; a chart type is usually a useful arrangement of several marks rather than a separate component.
| Visual task | Start with |
|---|---|
| Trend or connected path | lineY |
| Range, band, or filled trend | areaY, areaX |
| Category comparison | barY, barX |
| Interval, heatmap cell, or rectangular region | rect, cell |
| Relationship or individual observation | dot, hexagon |
| Baseline, threshold, or reference | ruleX, ruleY |
| Label or annotation | text |
| Directed relationship | arrow, link, vector |
| Compact distribution glyph | tickX, tickY |
| Plot frame | frame |
| Small-multiple composition | facet, facetChart |
| Pie, donut, gauge, or cyclic profile | polar and radial marks |
| Projected GeoJSON | geoShape |
Start from the analytical question in Choosing a Chart. The Mark Reference lists every channel and style option. Polar and geographic marks use explicit capability subpaths.
Marks earlier in the array paint behind later marks:
const marks = [
areaY(rangeRows, rangeOptions),
ruleY([target], ruleOptions),
lineY(actualRows, lineOptions),
dot(highlightedRows, dotOptions),
text(labels, textOptions),
]A useful default order is:
There is no separate overlay subsystem. An annotation is another mark with its own data, channels, and stable identity.
Every mark has an id. When omitted, it is derived from the mark type and array position.
Provide an explicit id when:
lineY(rows, {
id: 'actual-revenue',
x: 'date',
y: 'actual',
key: 'id',
})The datum key identifies observations within the layer. Use both stable mark IDs and datum keys for dynamic compositions.
z partitions geometry that should not connect:
lineY(rows, {
x: 'date',
y: 'value',
z: 'region',
key: 'id',
})Each region becomes an independent line. Area marks use the same grouping rule.
Bars use the primary band by default. When z values should form side-by-side bars, supply a configured D3 groupScale. When rows should stack, prepare explicit y1 and y2 or x1 and x2 intervals before the mark. TanStack Charts does not guess whether a group means color, dodge, stack, or overlap.
lineY and areaY split geometry at missing or invalid positional values:
interface Reading {
id: string
time: Date
low: number | null
high: number | null
}
areaY(readings, {
x: 'time',
y1: 'low',
y2: 'high',
key: 'id',
})The break is intentional evidence that no interval was materialized for that observation. Do not replace missing data with zero unless zero is semantically correct.
Bar and area marks accept explicit endpoints:
areaY(rows, {
x: 'date',
y1: 'minimum',
y2: 'maximum',
})
barY(rows, {
x: 'category',
y1: 'start',
y2: 'end',
})When y1 or x1 is omitted, bar and area baselines default to zero where that mark supports it. Supplying both endpoints makes the interval semantics explicit and includes both sides in scale materialization.
Rectangles are the general interval mark:
rect(windows, {
x1: 'start',
x2: 'end',
y1: 'minimum',
y2: 'maximum',
key: 'id',
})Some appearance options are constants:
lineY(rows, {
x: 'date',
y: 'value',
stroke: '#2563eb',
strokeWidth: 2.5,
strokeOpacity: 0.9,
})Marks that accept VisualChannel options can also derive a style from each row:
barX(rows, {
x: 'value',
y: 'label',
fill: (row) => (row.highlighted ? '#f97316' : '#94a3b8'),
})Use a semantic group or color channel and configured color scale when color represents data consistently across observations. Use a visual accessor for local styling that is not a shared scale.
Set clip: true on the chart definition when marks must not paint outside the resolved plot rectangle:
const chart = defineChart({
marks,
x: { scale: xScale },
y: { scale: yScale },
clip: true,
})Clipping applies to the chart’s mark group, not axes or legends. Leave it off when an intentional annotation or marker should extend beyond the plot.
import { scaleLinear, scaleUtc } from 'd3-scale'
import { areaY, defineChart, lineY } from '@tanstack/charts'
interface TemperatureRow {
id: string
date: Date
low: number
high: number
average: number
}
const rows: readonly TemperatureRow[] = [
{
id: 'mon',
date: new Date('2026-07-20T00:00:00Z'),
low: 16,
high: 27,
average: 21,
},
{
id: 'tue',
date: new Date('2026-07-21T00:00:00Z'),
low: 17,
high: 30,
average: 24,
},
{
id: 'wed',
date: new Date('2026-07-22T00:00:00Z'),
low: 15,
high: 26,
average: 20,
},
{
id: 'thu',
date: new Date('2026-07-23T00:00:00Z'),
low: 18,
high: 32,
average: 25,
},
{
id: 'fri',
date: new Date('2026-07-24T00:00:00Z'),
low: 19,
high: 34,
average: 27,
},
]
const temperatureChart = defineChart({
marks: [
areaY(rows, {
id: 'daily-range',
x: 'date',
y1: 'low',
y2: 'high',
key: 'id',
fill: '#60a5fa',
fillOpacity: 0.24,
}),
lineY(rows, {
id: 'daily-low',
x: 'date',
y: 'low',
key: 'id',
stroke: '#2563eb',
strokeWidth: 1.5,
}),
lineY(rows, {
id: 'daily-high',
x: 'date',
y: 'high',
key: 'id',
stroke: '#2563eb',
strokeWidth: 1.5,
}),
lineY(rows, {
id: 'daily-average',
x: 'date',
y: 'average',
key: 'id',
stroke: '#f97316',
strokeWidth: 2.5,
points: true,
}),
],
x: {
scale: scaleUtc,
label: 'Day',
},
y: {
scale: scaleLinear,
nice: true,
label: 'Temperature (°C)',
grid: true,
},
})This example directly imports d3-scale. Install it and @types/d3-scale as direct dependencies.
Use createMark when the needed geometry cannot be expressed by composing built-ins. A custom mark should still:
Most custom visualizations should remain compositions of built-in marks plus application-prepared rows. Reach for a custom mark only when the geometry itself is new. See Custom Marks and Renderers.