diff --git a/.changeset/bright-axis-titles.md b/.changeset/bright-axis-titles.md
new file mode 100644
index 00000000..2274bf4b
--- /dev/null
+++ b/.changeset/bright-axis-titles.md
@@ -0,0 +1,6 @@
+---
+'@tanstack/charts': minor
+---
+
+Allow Cartesian axis titles to configure font size, font weight, fill, and
+opacity while retaining automatic guide layout.
diff --git a/API-FRICTION.md b/API-FRICTION.md
index 5204435b..7361ebf1 100644
--- a/API-FRICTION.md
+++ b/API-FRICTION.md
@@ -5,7 +5,7 @@ observed difficulty from examples, production migrations, tests, and agent
evaluations so later API, documentation, and TanStack Intent skill work is
based on evidence.
-Last updated: 2026-08-26
+Last updated: 2026-08-27
## Triage rule
@@ -331,6 +331,7 @@ Each entry records:
| F-292 | Fixed preview paints ignored the selected site theme | Tooling | resolved |
| F-293 | Root scale slots blocked named axes | API | resolved |
| F-294 | Automatic mark renderers imposed shared host plumbing | API | resolved |
+| F-295 | Axis titles could not carry authored typography or paint | API | resolved |
## Findings
@@ -8505,3 +8506,28 @@ Each entry records:
React Native Metro gates, and framework package checks pass. Bundle boundary
checks keep SVG-only entries free of Canvas and measure the opt-in mixed
representative and React consumers at 35.68 KiB and 41.63 KiB gzip.
+
+### F-295 - Axis titles could not carry authored typography or paint
+
+- Status: resolved
+- Severity: medium
+- Owner: API
+- Observed in: migrating Rewardo dashboard charts to TanStack Charts on the
+ `experimental-tanstack-charts` branch and issue #93
+- Friction: tick labels accepted font size, weight, and opacity, but
+ `axis.label` accepted only text, offset, and motion. Rewardo's 14-pixel chart
+ typography and foreground treatment therefore required a generated-node CSS
+ override that Canvas and React Native could not honor.
+- Decision: extend the existing axis-label object with `fontSize`,
+ `fontWeight`, `fill`, and `opacity`. Keep the string form and every omitted
+ object field on the existing defaults. Resolve the options into the shared
+ scene label so automatic guide measurement and every renderer use one
+ contract.
+- Verification: scene layout covers configured typography, paint, default
+ compatibility, automatic margins, and shared facet titles. SVG, Canvas,
+ React Native, and motion tests exercise the same public definition. The
+ composed weather catalog case uses independently styled titles on three y
+ axes without renderer callbacks or CSS selectors. The reviewed shared-path
+ delta is 169 minified bytes and 29 gzip bytes for the locked line plus static
+ SVG consumer. A dedicated styled-title fixture adds 0.06 KiB gzip and no
+ retained modules over that consumer.
diff --git a/benchmarks/bundle-size/README.md b/benchmarks/bundle-size/README.md
index bb9c0e54..0b9230aa 100644
--- a/benchmarks/bundle-size/README.md
+++ b/benchmarks/bundle-size/README.md
@@ -37,6 +37,10 @@ gzip ceiling. The locked shared-host entries record the reviewed integration
cost, while noninteractive consumers retain only the small scene-compiler
portion of that contract.
+Cartesian axis-title styling is also part of the shared scene contract. Its
+static SVG fixture adds no retained modules over the ordinary line consumer
+and keeps authored title options under a 0.25 KiB incremental gzip ceiling.
+
Continuous viewports and the controlled interaction controller are also part
of the default scene and host contracts. Default static SVG consumes scene
clips and gradients. Their reviewed shared-path cost is recorded in the locked
diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json
index 486ae8f9..7c6584b7 100644
--- a/benchmarks/bundle-size/universal-baseline.json
+++ b/benchmarks/bundle-size/universal-baseline.json
@@ -3,44 +3,44 @@
"policy": "Exact minified and gzip output for entries that optional features must not affect. Review every change before updating.",
"bundles": {
"D3-scale line scene": {
- "bytes": 50813,
- "gzip": 19070
+ "bytes": 50982,
+ "gzip": 19097
},
"D3-scale line + static SVG": {
- "bytes": 55544,
- "gzip": 20776
+ "bytes": 55713,
+ "gzip": 20805
},
"Representative marks": {
- "bytes": 75617,
- "gzip": 27582
+ "bytes": 75788,
+ "gzip": 27619
},
"TanStack DOM host": {
- "bytes": 74947,
- "gzip": 26053
+ "bytes": 75118,
+ "gzip": 26064
},
"React adapter": {
- "bytes": 77130,
- "gzip": 26831
+ "bytes": 77301,
+ "gzip": 26854
},
"React line consumer": {
- "bytes": 100766,
- "gzip": 36222
+ "bytes": 100937,
+ "gzip": 36262
},
"Compact-scale line scene": {
- "bytes": 33265,
- "gzip": 11935
+ "bytes": 33436,
+ "gzip": 11953
},
"React compact-scale line consumer": {
- "bytes": 83266,
- "gzip": 29144
+ "bytes": 83437,
+ "gzip": 29177
},
"Custom-scale line scene": {
- "bytes": 31447,
- "gzip": 11193
+ "bytes": 31618,
+ "gzip": 11219
},
"D3 linear-scale line scene": {
- "bytes": 50745,
- "gzip": 19032
+ "bytes": 50914,
+ "gzip": 19060
}
}
}
diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json
index a22a54c1..71f88136 100644
--- a/benchmarks/comparison/bundle-baseline.json
+++ b/benchmarks/comparison/bundle-baseline.json
@@ -1,8 +1,8 @@
{
"schemaVersion": 4,
- "generatedAt": "2026-08-26T21:51:12.832Z",
+ "generatedAt": "2026-08-27T21:42:45.654Z",
"packageVersions": {
- "tanstack": "0.15.0",
+ "tanstack": "0.16.0",
"chartjs": "4.5.1",
"echarts": "6.1.0",
"recharts": "3.10.1",
@@ -12,7 +12,7 @@
"tanstack": {
"kind": "workspace",
"revision": "3df87d71f0305e5a450c10b66940f76f3e14259a",
- "inputDigest": "sha256:b1016e599e63d05e1647b9d27adabaaed7a38bb44dac8d6ba0fef06b8b8bd38e"
+ "inputDigest": "sha256:f6e6651fe8f30068b56208e243a35ea634593f85c7364b8403dfebfcb1e0d0f8"
},
"chartjs": {
"kind": "package",
@@ -45,88 +45,88 @@
},
"bundles": {
"tanstack-line-basic": {
- "minifiedBytes": 110431,
- "gzipBytes": 39891,
- "brotliBytes": 35325,
- "incrementalGzipBytes": 39891,
- "incrementalBrotliBytes": 35325
+ "minifiedBytes": 110602,
+ "gzipBytes": 39906,
+ "brotliBytes": 35329,
+ "incrementalGzipBytes": 39906,
+ "incrementalBrotliBytes": 35329
},
"tanstack-line-interactive": {
- "minifiedBytes": 115872,
- "gzipBytes": 41648,
- "brotliBytes": 36746,
- "incrementalGzipBytes": 41648,
- "incrementalBrotliBytes": 36746
+ "minifiedBytes": 116043,
+ "gzipBytes": 41675,
+ "brotliBytes": 36794,
+ "incrementalGzipBytes": 41675,
+ "incrementalBrotliBytes": 36794
},
"tanstack-line-advanced": {
- "minifiedBytes": 123062,
- "gzipBytes": 43974,
- "brotliBytes": 38764,
- "incrementalGzipBytes": 43974,
- "incrementalBrotliBytes": 38764
+ "minifiedBytes": 123233,
+ "gzipBytes": 44002,
+ "brotliBytes": 38748,
+ "incrementalGzipBytes": 44002,
+ "incrementalBrotliBytes": 38748
},
"tanstack-bar-basic": {
- "minifiedBytes": 119225,
- "gzipBytes": 43210,
- "brotliBytes": 38168,
- "incrementalGzipBytes": 43210,
- "incrementalBrotliBytes": 38168
+ "minifiedBytes": 119396,
+ "gzipBytes": 43218,
+ "brotliBytes": 38198,
+ "incrementalGzipBytes": 43218,
+ "incrementalBrotliBytes": 38198
},
"tanstack-bar-interactive": {
- "minifiedBytes": 123521,
- "gzipBytes": 44585,
- "brotliBytes": 39223,
- "incrementalGzipBytes": 44585,
- "incrementalBrotliBytes": 39223
+ "minifiedBytes": 123692,
+ "gzipBytes": 44605,
+ "brotliBytes": 39267,
+ "incrementalGzipBytes": 44605,
+ "incrementalBrotliBytes": 39267
},
"tanstack-bar-advanced": {
- "minifiedBytes": 123860,
- "gzipBytes": 44735,
- "brotliBytes": 39349,
- "incrementalGzipBytes": 44735,
- "incrementalBrotliBytes": 39349
+ "minifiedBytes": 124031,
+ "gzipBytes": 44749,
+ "brotliBytes": 39341,
+ "incrementalGzipBytes": 44749,
+ "incrementalBrotliBytes": 39341
},
"tanstack-area-basic": {
- "minifiedBytes": 115640,
- "gzipBytes": 41867,
- "brotliBytes": 37073,
- "incrementalGzipBytes": 41867,
- "incrementalBrotliBytes": 37073
+ "minifiedBytes": 115811,
+ "gzipBytes": 41866,
+ "brotliBytes": 37072,
+ "incrementalGzipBytes": 41866,
+ "incrementalBrotliBytes": 37072
},
"tanstack-area-interactive": {
- "minifiedBytes": 121085,
- "gzipBytes": 43594,
- "brotliBytes": 38493,
- "incrementalGzipBytes": 43594,
- "incrementalBrotliBytes": 38493
+ "minifiedBytes": 121256,
+ "gzipBytes": 43620,
+ "brotliBytes": 38585,
+ "incrementalGzipBytes": 43620,
+ "incrementalBrotliBytes": 38585
},
"tanstack-area-advanced": {
- "minifiedBytes": 128457,
- "gzipBytes": 46057,
- "brotliBytes": 40527,
- "incrementalGzipBytes": 46057,
- "incrementalBrotliBytes": 40527
+ "minifiedBytes": 128628,
+ "gzipBytes": 46064,
+ "brotliBytes": 40628,
+ "incrementalGzipBytes": 46064,
+ "incrementalBrotliBytes": 40628
},
"tanstack-scatter-basic": {
- "minifiedBytes": 111460,
- "gzipBytes": 40266,
- "brotliBytes": 35596,
- "incrementalGzipBytes": 40266,
- "incrementalBrotliBytes": 35596
+ "minifiedBytes": 111631,
+ "gzipBytes": 40284,
+ "brotliBytes": 35628,
+ "incrementalGzipBytes": 40284,
+ "incrementalBrotliBytes": 35628
},
"tanstack-scatter-interactive": {
- "minifiedBytes": 116901,
- "gzipBytes": 42015,
- "brotliBytes": 36976,
- "incrementalGzipBytes": 42015,
- "incrementalBrotliBytes": 36976
+ "minifiedBytes": 117072,
+ "gzipBytes": 42044,
+ "brotliBytes": 37051,
+ "incrementalGzipBytes": 42044,
+ "incrementalBrotliBytes": 37051
},
"tanstack-scatter-advanced": {
- "minifiedBytes": 116917,
- "gzipBytes": 42020,
- "brotliBytes": 37012,
- "incrementalGzipBytes": 42020,
- "incrementalBrotliBytes": 37012
+ "minifiedBytes": 117088,
+ "gzipBytes": 42050,
+ "brotliBytes": 37043,
+ "incrementalGzipBytes": 42050,
+ "incrementalBrotliBytes": 37043
},
"chartjs-line-basic": {
"minifiedBytes": 137909,
diff --git a/benchmarks/conformance/cases/70-composed-chart/case.json b/benchmarks/conformance/cases/70-composed-chart/case.json
index 62dac152..bb1368ee 100644
--- a/benchmarks/conformance/cases/70-composed-chart/case.json
+++ b/benchmarks/conformance/cases/70-composed-chart/case.json
@@ -11,6 +11,7 @@
"mixed marks",
"independent y scales",
"left axis and stacked right axes",
+ "styled axis titles",
"daily observations",
"monotone curves",
"resolved maximum bar thickness"
diff --git a/benchmarks/conformance/cases/70-composed-chart/example.tsx b/benchmarks/conformance/cases/70-composed-chart/example.tsx
index 02187acb..78b372ad 100644
--- a/benchmarks/conformance/cases/70-composed-chart/example.tsx
+++ b/benchmarks/conformance/cases/70-composed-chart/example.tsx
@@ -63,7 +63,13 @@ export const createExampleChart = (input: ChartOptions) => {
scale: scaleLinear,
grid: true,
axis: {
- label: 'Temperature (°C)',
+ label: {
+ text: 'Temperature (°C)',
+ fontSize: 12,
+ fontWeight: 700,
+ fill: '#8884d8',
+ opacity: 0.8,
+ },
ticks: { count: 5 },
},
},
@@ -72,7 +78,13 @@ export const createExampleChart = (input: ChartOptions) => {
side: 'right',
scale: scaleLinear,
axis: {
- label: 'Precipitation (mm)',
+ label: {
+ text: 'Precipitation (mm)',
+ fontSize: 12,
+ fontWeight: 700,
+ fill: '#413ea0',
+ opacity: 0.8,
+ },
ticks: { count: 5 },
},
},
@@ -81,7 +93,13 @@ export const createExampleChart = (input: ChartOptions) => {
side: 'right',
scale: scaleLinear,
axis: {
- label: 'Wind (m/s)',
+ label: {
+ text: 'Wind (m/s)',
+ fontSize: 12,
+ fontWeight: 700,
+ fill: '#ef4444',
+ opacity: 0.8,
+ },
ticks: { count: 5 },
},
},
diff --git a/benchmarks/conformance/cases/70-composed-chart/recharts.ts b/benchmarks/conformance/cases/70-composed-chart/recharts.ts
index 24a7d097..71443f44 100644
--- a/benchmarks/conformance/cases/70-composed-chart/recharts.ts
+++ b/benchmarks/conformance/cases/70-composed-chart/recharts.ts
@@ -55,6 +55,10 @@ function chart(input: ConformanceInput) {
value: 'Temperature (°C)',
angle: -90,
position: 'insideLeft',
+ fill: '#8884d8',
+ fontSize: 12,
+ fontWeight: 700,
+ opacity: 0.8,
},
}),
createElement(YAxis, {
@@ -67,6 +71,10 @@ function chart(input: ConformanceInput) {
value: 'Precipitation (mm)',
angle: 90,
position: 'insideRight',
+ fill: '#413ea0',
+ fontSize: 12,
+ fontWeight: 700,
+ opacity: 0.8,
},
}),
createElement(YAxis, {
@@ -79,6 +87,10 @@ function chart(input: ConformanceInput) {
value: 'Wind (m/s)',
angle: 90,
position: 'insideRight',
+ fill: '#ef4444',
+ fontSize: 12,
+ fontWeight: 700,
+ opacity: 0.8,
},
}),
createElement(Area, {
diff --git a/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts b/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts
index ec59bc74..7df727ab 100644
--- a/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts
+++ b/benchmarks/conformance/cases/70-composed-chart/tanstack.test.ts
@@ -2,7 +2,7 @@ import { createChartRuntime } from '@tanstack/charts'
import { describe, expect, it } from 'vitest'
import { loadTanStackSources } from '../../native-catalog'
import { createExampleChart } from './tanstack'
-import type { SceneNode, SceneRule } from '@tanstack/charts'
+import type { SceneLabel, SceneNode, SceneRule } from '@tanstack/charts'
import type { ConformanceInput } from '../../types'
const input = {
@@ -67,6 +67,36 @@ describe('native composed-chart bar sizing', () => {
expect(windAxis?.x1).toBeGreaterThan(precipitationAxis?.x1 ?? Infinity)
})
+ it('styles each axis title through the chart definition', () => {
+ const titles = flatten(render(input).nodes).filter(
+ (node): node is SceneLabel =>
+ node.kind === 'label' && node.key.endsWith('-label'),
+ )
+
+ expect(titles).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ text: 'Temperature (°C)',
+ fontSize: 12,
+ fontWeight: 700,
+ style: { fill: '#8884d8', opacity: 0.8 },
+ }),
+ expect.objectContaining({
+ text: 'Precipitation (mm)',
+ fontSize: 12,
+ fontWeight: 700,
+ style: { fill: '#413ea0', opacity: 0.8 },
+ }),
+ expect.objectContaining({
+ text: 'Wind (m/s)',
+ fontSize: 12,
+ fontWeight: 700,
+ style: { fill: '#ef4444', opacity: 0.8 },
+ }),
+ ]),
+ )
+ })
+
it('does not hide responsive bar geometry outside the definition', async () => {
const closure = await loadTanStackSources('70-composed-chart')
const source = closure.files.map((file) => file.source).join('\n')
diff --git a/benchmarks/conformance/catalog-index.json b/benchmarks/conformance/catalog-index.json
index 9d3c84f0..56c01564 100644
--- a/benchmarks/conformance/catalog-index.json
+++ b/benchmarks/conformance/catalog-index.json
@@ -2392,6 +2392,7 @@
"mixed marks",
"independent y scales",
"left axis and stacked right axes",
+ "styled axis titles",
"daily observations",
"monotone curves",
"resolved maximum bar thickness"
diff --git a/benchmarks/conformance/previews/70-composed-chart.svg b/benchmarks/conformance/previews/70-composed-chart.svg
index 5de7df71..3bb5b3d1 100644
--- a/benchmarks/conformance/previews/70-composed-chart.svg
+++ b/benchmarks/conformance/previews/70-composed-chart.svg
@@ -1 +1 @@
-
+
diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json
index e1176205..487e8ed8 100644
--- a/benchmarks/conformance/previews/manifest.json
+++ b/benchmarks/conformance/previews/manifest.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"width": 288,
"height": 192,
- "sourceHash": "d3177faf65fca2414b431c195a003cf9afdceb8ae2f1b37e4f8cb81578dc8293",
+ "sourceHash": "e8fb24cd96d6fbfbb8327eb97ab506f7f60fd4870baa986a725da75d736c87b9",
"assets": [
{
"id": "01-line-gaps",
@@ -306,8 +306,8 @@
},
{
"id": "70-composed-chart",
- "sha256": "642139631cae72ef76ff81e148e6d40e2ff83225a11b582a32878d0f166beae7",
- "bytes": 14436
+ "sha256": "9d79b12f0fbcf14f8bfa4115ea0ee75147fe0d9fa27ca2ec20a0f29a50857fc0",
+ "bytes": 14480
},
{
"id": "71-recharts-population-pyramid",
diff --git a/benchmarks/entries/charts-axis-label-styles.ts b/benchmarks/entries/charts-axis-label-styles.ts
new file mode 100644
index 00000000..a0c82ad6
--- /dev/null
+++ b/benchmarks/entries/charts-axis-label-styles.ts
@@ -0,0 +1,32 @@
+import {
+ createChartScene,
+ defineChart,
+ lineY,
+ renderChartSvg,
+} from '@tanstack/charts'
+import { scaleLinear } from 'd3-scale'
+
+const definition = defineChart({
+ marks: [lineY([4, 9, 7])],
+ scales: {
+ x: {
+ scale: scaleLinear().domain([0, 2]),
+ axis: {
+ label: {
+ text: 'Quarter',
+ fontSize: 13,
+ fontWeight: 700,
+ fill: '#2563eb',
+ opacity: 0.8,
+ },
+ },
+ },
+ y: { scale: scaleLinear().domain([0, 10]) },
+ },
+})
+
+export function render(width: number, height: number) {
+ return renderChartSvg(createChartScene(definition, { width, height }), {
+ ariaLabel: 'Styled axis title',
+ })
+}
diff --git a/docs/comparison.md b/docs/comparison.md
index 912adf20..83f66b70 100644
--- a/docs/comparison.md
+++ b/docs/comparison.md
@@ -90,7 +90,7 @@ output model.
## Bundle snapshot
-Baseline date: `2026-08-26`.
+Baseline date: `2026-08-27`.
Controlled ranges cover 12 independently built, minified browser consumers:
line, bar, area, and scatter at basic, interactive, and advanced tiers. Only
@@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July
| Library | Bundle size | React externalized | Evidence |
| ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- |
-| TanStack Charts | 38.96–44.98 KiB | Not applicable | Controlled suite |
+| TanStack Charts | 38.97–44.98 KiB | Not applicable | Controlled suite |
| D3 | 90 KB gzip | — | External main export |
| Chart.js | 44.70–58.21 KiB | — | Controlled suite |
| Apache ECharts | 153.10–173.18 KiB | — | Controlled suite |
diff --git a/docs/concepts/layout-axes-and-coordinates.md b/docs/concepts/layout-axes-and-coordinates.md
index 9aa67858..ae21a276 100644
--- a/docs/concepts/layout-axes-and-coordinates.md
+++ b/docs/concepts/layout-axes-and-coordinates.md
@@ -152,20 +152,27 @@ const x = {
format: (date: Date) => monthFormatter.format(date),
},
tickLabels: { rotate: -30 },
- label: { text: 'Month', offset: 12 },
+ label: {
+ text: 'Month',
+ offset: 12,
+ fontSize: 13,
+ fontWeight: 600,
+ fill: '#334155',
+ opacity: 0.9,
+ },
},
}
```
-| Option | Purpose |
-| ----------------- | ---------------------------------------------------- |
-| `axis` | Configure the axis or hide it with `false` |
-| `axis.line` | Show or hide the baseline |
-| `axis.ticks` | Configure candidates, stubs, padding, and formatting |
-| `axis.tickLabels` | Configure label rotation and collision thinning |
-| `axis.label` | Configure the axis title and offset |
-| `grid` | Draw grid lines at semantic candidates |
-| `reverse` | Reverse the responsive range |
+| Option | Purpose |
+| ----------------- | -------------------------------------------------------- |
+| `axis` | Configure the axis or hide it with `false` |
+| `axis.line` | Show or hide the baseline |
+| `axis.ticks` | Configure candidates, stubs, padding, and formatting |
+| `axis.tickLabels` | Configure label rotation and collision thinning |
+| `axis.label` | Configure axis title text, typography, paint, and offset |
+| `grid` | Draw grid lines at semantic candidates |
+| `reverse` | Reverse the responsive range |
The y grid defaults to visible and the x grid defaults to hidden when `grid` is omitted.
diff --git a/docs/guides/themes-and-styling.md b/docs/guides/themes-and-styling.md
index cbea3392..1cd79e40 100644
--- a/docs/guides/themes-and-styling.md
+++ b/docs/guides/themes-and-styling.md
@@ -93,6 +93,37 @@ Keep these responsibilities separate:
For categorical or quantitative color mapping, use the canonical
[Legends and Color](./legends-and-color.md) guide.
+## Axis title styling
+
+String axis titles use the chart foreground and built-in title typography.
+Use the object form when one title needs an explicit text color, opacity, size,
+weight, or offset:
+
+```ts
+const definition = defineChart({
+ marks,
+ scales: {
+ x: { scale: xScale, axis: { label: 'Month' } },
+ y: {
+ scale: yScale,
+ axis: {
+ label: {
+ text: 'Revenue (USD)',
+ fontSize: 14,
+ fontWeight: 700,
+ fill: '#2563eb',
+ opacity: 0.85,
+ },
+ },
+ },
+ },
+})
+```
+
+`fill` is the title's text color. The layout measures the configured font size
+and weight before resolving automatic guide margins, so larger titles do not
+need matching manual margins.
+
## Canvas styling
The Canvas renderer resolves scene paints such as `currentColor` and CSS
diff --git a/docs/reference/scales-guides-and-color.md b/docs/reference/scales-guides-and-color.md
index 69af1cab..b8913e52 100644
--- a/docs/reference/scales-guides-and-color.md
+++ b/docs/reference/scales-guides-and-color.md
@@ -228,7 +228,17 @@ interface ChartAxisOptions {
keep?: readonly TValue[]
}
}
- label?: string | { text: string; offset?: number | 'auto' }
+ label?:
+ | string
+ | {
+ text: string
+ offset?: number | 'auto'
+ fontSize?: number
+ fontWeight?: number
+ fill?: string
+ opacity?: number
+ motion?: ChartMotionDefinition
+ }
}
}
```
@@ -349,6 +359,30 @@ weight, anchor, offset, opacity, and rotation all participate in collision
thinning and automatic margins. Numeric typography follows tick-label motion;
anchor changes snap.
+Axis titles keep the compact string form when only text is needed. Use the
+object form for title typography, paint, offset, or motion:
+
+```ts
+const y = {
+ scale: scaleLinear,
+ axis: {
+ label: {
+ text: 'Average order value (PLN)',
+ fontSize: 14,
+ fontWeight: 500,
+ fill: '#363636',
+ opacity: 0.9,
+ },
+ },
+}
+```
+
+`fill` sets the text color. Omitted presentation fields retain the existing
+title defaults: responsive 10 or 11 pixel sizing on x, 11 pixels on y, weight
+600, theme foreground fill, and 0.76 fill opacity. Configured font size and
+weight participate in text measurement, automatic offsets, and automatic
+margins for SVG, Canvas, and native rendering.
+
## Named scales and multiple axes
The reserved `x` and `y` entries are the default bindings for Cartesian marks.
diff --git a/docs/reference/types.md b/docs/reference/types.md
index f219b23b..34536583 100644
--- a/docs/reference/types.md
+++ b/docs/reference/types.md
@@ -218,7 +218,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes).
| `ChartAxisTickLabelContext` | Semantic value, stable candidate index, pixel position, and bandwidth |
| `ChartAxisTickLabelValue` | Constant or context accessor for one tick-label property |
| `ChartAxisTickLabelThinOptions` | Minimum gap, end priority, and labels that must be kept |
-| `ChartAxisLabelOptions` | Axis title text and explicit or measured offset |
+| `ChartAxisLabelOptions` | Axis title text, typography, paint, motion, and offset |
| `ChartScaleFactory` | Creates a positional scale with a mark-inferred domain |
| `ChartScaleInput` | Factory or configured positional scale instance |
| `InferableScaleLike` | Domain-configurable scale returned by a factory |
diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md
index 912adf20..83f66b70 100644
--- a/packages/charts-core/docs/comparison.md
+++ b/packages/charts-core/docs/comparison.md
@@ -90,7 +90,7 @@ output model.
## Bundle snapshot
-Baseline date: `2026-08-26`.
+Baseline date: `2026-08-27`.
Controlled ranges cover 12 independently built, minified browser consumers:
line, bar, area, and scatter at basic, interactive, and advanced tiers. Only
@@ -106,7 +106,7 @@ Vega-Lite, AG Charts, and uPlot main exports were read from Bundlephobia on July
| Library | Bundle size | React externalized | Evidence |
| ------------------ | -------------------------------------- | -----------------: | ---------------------------------------------------------- |
-| TanStack Charts | 38.96–44.98 KiB | Not applicable | Controlled suite |
+| TanStack Charts | 38.97–44.98 KiB | Not applicable | Controlled suite |
| D3 | 90 KB gzip | — | External main export |
| Chart.js | 44.70–58.21 KiB | — | Controlled suite |
| Apache ECharts | 153.10–173.18 KiB | — | Controlled suite |
diff --git a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md
index 9aa67858..ae21a276 100644
--- a/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md
+++ b/packages/charts-core/docs/concepts/layout-axes-and-coordinates.md
@@ -152,20 +152,27 @@ const x = {
format: (date: Date) => monthFormatter.format(date),
},
tickLabels: { rotate: -30 },
- label: { text: 'Month', offset: 12 },
+ label: {
+ text: 'Month',
+ offset: 12,
+ fontSize: 13,
+ fontWeight: 600,
+ fill: '#334155',
+ opacity: 0.9,
+ },
},
}
```
-| Option | Purpose |
-| ----------------- | ---------------------------------------------------- |
-| `axis` | Configure the axis or hide it with `false` |
-| `axis.line` | Show or hide the baseline |
-| `axis.ticks` | Configure candidates, stubs, padding, and formatting |
-| `axis.tickLabels` | Configure label rotation and collision thinning |
-| `axis.label` | Configure the axis title and offset |
-| `grid` | Draw grid lines at semantic candidates |
-| `reverse` | Reverse the responsive range |
+| Option | Purpose |
+| ----------------- | -------------------------------------------------------- |
+| `axis` | Configure the axis or hide it with `false` |
+| `axis.line` | Show or hide the baseline |
+| `axis.ticks` | Configure candidates, stubs, padding, and formatting |
+| `axis.tickLabels` | Configure label rotation and collision thinning |
+| `axis.label` | Configure axis title text, typography, paint, and offset |
+| `grid` | Draw grid lines at semantic candidates |
+| `reverse` | Reverse the responsive range |
The y grid defaults to visible and the x grid defaults to hidden when `grid` is omitted.
diff --git a/packages/charts-core/docs/guides/themes-and-styling.md b/packages/charts-core/docs/guides/themes-and-styling.md
index cbea3392..1cd79e40 100644
--- a/packages/charts-core/docs/guides/themes-and-styling.md
+++ b/packages/charts-core/docs/guides/themes-and-styling.md
@@ -93,6 +93,37 @@ Keep these responsibilities separate:
For categorical or quantitative color mapping, use the canonical
[Legends and Color](./legends-and-color.md) guide.
+## Axis title styling
+
+String axis titles use the chart foreground and built-in title typography.
+Use the object form when one title needs an explicit text color, opacity, size,
+weight, or offset:
+
+```ts
+const definition = defineChart({
+ marks,
+ scales: {
+ x: { scale: xScale, axis: { label: 'Month' } },
+ y: {
+ scale: yScale,
+ axis: {
+ label: {
+ text: 'Revenue (USD)',
+ fontSize: 14,
+ fontWeight: 700,
+ fill: '#2563eb',
+ opacity: 0.85,
+ },
+ },
+ },
+ },
+})
+```
+
+`fill` is the title's text color. The layout measures the configured font size
+and weight before resolving automatic guide margins, so larger titles do not
+need matching manual margins.
+
## Canvas styling
The Canvas renderer resolves scene paints such as `currentColor` and CSS
diff --git a/packages/charts-core/docs/reference/scales-guides-and-color.md b/packages/charts-core/docs/reference/scales-guides-and-color.md
index 69af1cab..b8913e52 100644
--- a/packages/charts-core/docs/reference/scales-guides-and-color.md
+++ b/packages/charts-core/docs/reference/scales-guides-and-color.md
@@ -228,7 +228,17 @@ interface ChartAxisOptions {
keep?: readonly TValue[]
}
}
- label?: string | { text: string; offset?: number | 'auto' }
+ label?:
+ | string
+ | {
+ text: string
+ offset?: number | 'auto'
+ fontSize?: number
+ fontWeight?: number
+ fill?: string
+ opacity?: number
+ motion?: ChartMotionDefinition
+ }
}
}
```
@@ -349,6 +359,30 @@ weight, anchor, offset, opacity, and rotation all participate in collision
thinning and automatic margins. Numeric typography follows tick-label motion;
anchor changes snap.
+Axis titles keep the compact string form when only text is needed. Use the
+object form for title typography, paint, offset, or motion:
+
+```ts
+const y = {
+ scale: scaleLinear,
+ axis: {
+ label: {
+ text: 'Average order value (PLN)',
+ fontSize: 14,
+ fontWeight: 500,
+ fill: '#363636',
+ opacity: 0.9,
+ },
+ },
+}
+```
+
+`fill` sets the text color. Omitted presentation fields retain the existing
+title defaults: responsive 10 or 11 pixel sizing on x, 11 pixels on y, weight
+600, theme foreground fill, and 0.76 fill opacity. Configured font size and
+weight participate in text measurement, automatic offsets, and automatic
+margins for SVG, Canvas, and native rendering.
+
## Named scales and multiple axes
The reserved `x` and `y` entries are the default bindings for Cartesian marks.
diff --git a/packages/charts-core/docs/reference/types.md b/packages/charts-core/docs/reference/types.md
index f219b23b..34536583 100644
--- a/packages/charts-core/docs/reference/types.md
+++ b/packages/charts-core/docs/reference/types.md
@@ -218,7 +218,7 @@ See [Scene nodes](./runtime-and-scene.md#scene-nodes).
| `ChartAxisTickLabelContext` | Semantic value, stable candidate index, pixel position, and bandwidth |
| `ChartAxisTickLabelValue` | Constant or context accessor for one tick-label property |
| `ChartAxisTickLabelThinOptions` | Minimum gap, end priority, and labels that must be kept |
-| `ChartAxisLabelOptions` | Axis title text and explicit or measured offset |
+| `ChartAxisLabelOptions` | Axis title text, typography, paint, motion, and offset |
| `ChartScaleFactory` | Creates a positional scale with a mark-inferred domain |
| `ChartScaleInput` | Factory or configured positional scale instance |
| `InferableScaleLike` | Domain-configurable scale returned by a factory |
diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts
index 5ce7d73c..8cc59ba8 100644
--- a/packages/charts-core/src/canvas.test.ts
+++ b/packages/charts-core/src/canvas.test.ts
@@ -37,6 +37,11 @@ import type { ChartScene, SceneNode } from './types'
interface FakeCanvasContext {
operations: string[]
gradientStops: Array<[number, string]>
+ textPaints: Array<{
+ text: string
+ globalAlpha: number
+ font: string
+ }>
context: CanvasRenderingContext2D
}
@@ -988,6 +993,49 @@ describe('Canvas renderer', () => {
surface.destroy()
})
+ it('paints Cartesian axis-title typography and paint', () => {
+ const definition = defineChart({
+ marks: [lineY([1, 2, 3])],
+ scales: {
+ x: { scale: scaleLinear().domain([0, 2]), axis: false },
+ y: {
+ scale: scaleLinear().domain([0, 3]),
+ axis: {
+ ticks: false,
+ label: {
+ text: 'Revenue',
+ fontSize: 17,
+ fontWeight: 650,
+ fill: '#0f766e',
+ opacity: 0.6,
+ },
+ },
+ },
+ },
+ })
+ const container = document.createElement('div')
+ const surface = canvasChartRenderer.mount(container, () => {})
+ const generated = createChartScene(definition, {
+ width: 480,
+ height: 260,
+ })
+ const title = generated.nodes
+ .flatMap((node) => (node.kind === 'group' ? node.children : [node]))
+ .find((node) => node.key === 'y-label')
+
+ expect(title?.style?.fill).toBe('#0f766e')
+ surface.render(generated, { ariaLabel: 'Revenue chart' })
+
+ const painted = contexts
+ .get(surface.sceneCanvas)
+ ?.textPaints.find(({ text }) => text === 'Revenue')
+ expect(painted).toMatchObject({
+ globalAlpha: 0.6,
+ })
+ expect(painted?.font).toMatch(/650 17px/)
+ surface.destroy()
+ })
+
it('snaps retargeting focus candidates without adding interaction points', () => {
const container = document.createElement('div')
const surface = createCanvasChartRenderer().mount(container, () => {})
@@ -2432,6 +2480,7 @@ function renderOptions(): ChartSurfaceRenderOptions {
function fakeContext(): FakeCanvasContext {
const operations: string[] = []
const gradientStops: Array<[number, string]> = []
+ const textPaints: FakeCanvasContext['textPaints'] = []
let fillStyle: string | CanvasGradient = '#000000'
let strokeStyle: string | CanvasGradient = '#000000'
let lineWidth = 1
@@ -2480,8 +2529,14 @@ function fakeContext(): FakeCanvasContext {
`stroke:${String(strokeStyle)}:${lineWidth}:${globalAlpha}`,
)
},
- fillText: (text: string, x: number, y: number) =>
- operations.push(`fillText:${text},${x},${y}`),
+ fillText: (text: string, x: number, y: number) => {
+ operations.push(`fillText:${text},${x},${y}`)
+ textPaints.push({
+ text,
+ globalAlpha,
+ font: context.font,
+ })
+ },
strokeText: (text: string, x: number, y: number) =>
operations.push(`strokeText:${text},${x},${y}`),
setLineDash: (values: number[]) =>
@@ -2537,5 +2592,5 @@ function fakeContext(): FakeCanvasContext {
textAlign: 'left',
textBaseline: 'alphabetic',
} as unknown as CanvasRenderingContext2D
- return { operations, gradientStops, context }
+ return { operations, gradientStops, textPaints, context }
}
diff --git a/packages/charts-core/src/facet.test.ts b/packages/charts-core/src/facet.test.ts
index e1d0fd43..0089eb40 100644
--- a/packages/charts-core/src/facet.test.ts
+++ b/packages/charts-core/src/facet.test.ts
@@ -178,7 +178,15 @@ describe('facets', () => {
},
y: {
scale: scaleLinear().domain([0, 6]),
- axis: { label: 'Vertical' },
+ axis: {
+ label: {
+ text: 'Vertical',
+ fontSize: 18,
+ fontWeight: 700,
+ fill: '#7c3aed',
+ opacity: 0.5,
+ },
+ },
},
},
}),
@@ -201,6 +209,10 @@ describe('facets', () => {
)
const aPoints = scene.points.filter((point) => point.datum.group === 'A')
const bPoints = scene.points.filter((point) => point.datum.group === 'B')
+ const yTitle = nodes.find(
+ (node): node is Extract =>
+ node.kind === 'label' && node.key === 'facet-0:y-label',
+ )
expect(yAxes).toHaveLength(1)
expect(xAxes).toHaveLength(2)
@@ -210,6 +222,11 @@ describe('facets', () => {
expect(nodes.filter((node) => node.key === 'facet-0:y-label')).toHaveLength(
1,
)
+ expect(yTitle).toMatchObject({
+ fontSize: 18,
+ fontWeight: 700,
+ style: { fill: '#7c3aed', opacity: 0.5 },
+ })
expect(yLabels).toHaveLength(
new Set(yLabels.map((node) => (node.kind === 'label' ? node.text : '')))
.size,
diff --git a/packages/charts-core/src/motion.test.ts b/packages/charts-core/src/motion.test.ts
index a8f24f91..e5cb2300 100644
--- a/packages/charts-core/src/motion.test.ts
+++ b/packages/charts-core/src/motion.test.ts
@@ -2106,6 +2106,72 @@ describe('SVG motion', () => {
frames.restore()
})
+ it('applies axis-title motion to configured typography and opacity', () => {
+ const definition = (
+ fontSize: number,
+ fontWeight: number,
+ opacity: number,
+ ) =>
+ defineChart({
+ marks: [lineY([0, 1])],
+ margin: 0,
+ scales: {
+ x: { scale: scaleLinear().domain([0, 1]), axis: false },
+ y: {
+ scale: scaleLinear().domain([0, 1]),
+ axis: {
+ ticks: false,
+ label: {
+ text: 'Revenue',
+ fontSize,
+ fontWeight,
+ opacity,
+ motion: {
+ transition: {
+ type: 'tween',
+ duration: 100,
+ easing: 'linear',
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ const first = createChartScene(definition(10, 400, 0.4), {
+ width: 300,
+ height: 200,
+ })
+ const next = createChartScene(definition(20, 700, 0.8), {
+ width: 300,
+ height: 200,
+ })
+ const container = document.createElement('div')
+ const surface = motion({ initial: false }).mount(container, () => {})
+ surface.render(first, { ariaLabel: 'Axis title typography' })
+ const frames = installManagedFrames()
+ surface.render(next, { ariaLabel: 'Axis title typography' })
+ const label = container.querySelector(
+ '[data-ts-key="y-label"]',
+ )
+
+ expect(label?.getAttribute('font-size')).toBe('10')
+ expect(label?.getAttribute('font-weight')).toBe('400')
+ expect(label?.getAttribute('opacity')).toBe('0.4')
+ frames.run(0)
+ frames.run(50)
+ expect(Number(label?.getAttribute('font-size'))).toBeCloseTo(15)
+ expect(Number(label?.getAttribute('font-weight'))).toBeCloseTo(550)
+ expect(Number(label?.getAttribute('opacity'))).toBeCloseTo(0.6)
+ frames.run(100)
+ expect(label?.getAttribute('font-size')).toBe('20')
+ expect(label?.getAttribute('font-weight')).toBe('700')
+ expect(label?.getAttribute('opacity')).toBe('0.8')
+
+ surface.destroy()
+ frames.restore()
+ })
+
it('reports radial-bar paths with the semantic bar motion role', () => {
const roles: string[] = []
const definition = (value: number) =>
diff --git a/packages/charts-core/src/scene-layout.test.ts b/packages/charts-core/src/scene-layout.test.ts
index 8d2d8492..9b3b7aeb 100644
--- a/packages/charts-core/src/scene-layout.test.ts
+++ b/packages/charts-core/src/scene-layout.test.ts
@@ -6,6 +6,7 @@ import { createMark } from './mark'
import { createChartScene, defineChart } from './scene'
import { text } from './text'
import type {
+ ChartAxisLabelOptions,
ChartAxisTickLabelContext,
ChartAxisTickLabelOptions,
ChartAxisTickLabelValue,
@@ -240,6 +241,97 @@ describe('automatic scene guide layout', () => {
expect(yLabels.every(({ anchor }) => anchor === 'end')).toBe(true)
})
+ it('applies axis-title typography and paint while preserving string-label defaults', () => {
+ const xLabel = {
+ text: 'Styled horizontal title',
+ fontSize: 18,
+ fontWeight: 700,
+ fill: '#7c3aed',
+ opacity: 0.45,
+ } satisfies ChartAxisLabelOptions
+ const scene = createChartScene(
+ defineChart({
+ marks: [lineY([0, 1])],
+ margin: 0,
+ scales: {
+ x: {
+ scale: scaleLinear().domain([0, 1]),
+ axis: { label: xLabel },
+ },
+ y: {
+ scale: scaleLinear().domain([0, 1]),
+ axis: { label: 'Default vertical title' },
+ },
+ },
+ }),
+ { width: 480, height: 240 },
+ { measureText },
+ )
+ const labels = flatten(scene.nodes).filter(
+ (node): node is SceneLabel => node.kind === 'label',
+ )
+ const styled = labels.find(({ key }) => key === 'x-label')
+ const defaulted = labels.find(({ key }) => key === 'y-label')
+
+ expect(styled).toMatchObject({
+ text: xLabel.text,
+ fontSize: 18,
+ fontWeight: 700,
+ style: { fill: '#7c3aed', opacity: 0.45 },
+ })
+ expect(defaulted).toMatchObject({
+ text: 'Default vertical title',
+ fontSize: 11,
+ fontWeight: 600,
+ style: { fill: 'currentColor', fillOpacity: 0.76 },
+ })
+ })
+
+ it('measures configured axis-title typography into automatic margins', () => {
+ const definition = (fontSize: number, fontWeight: number) =>
+ defineChart({
+ marks: [lineY([0, 1])],
+ scales: {
+ x: { scale: scaleLinear().domain([0, 1]), axis: false },
+ y: {
+ scale: scaleLinear().domain([0, 1]),
+ axis: {
+ ticks: false,
+ label: {
+ text: 'Average order value',
+ fontSize,
+ fontWeight,
+ },
+ },
+ },
+ },
+ })
+ const measured = vi.fn(measureText)
+ const regular = createChartScene(
+ definition(11, 600),
+ { width: 480, height: 240 },
+ { measureText: measured },
+ )
+ const enlarged = createChartScene(
+ definition(24, 750),
+ { width: 480, height: 240 },
+ { measureText: measured },
+ )
+
+ expect(enlarged.margin.left).toBeGreaterThan(regular.margin.left)
+ expect(measured).toHaveBeenCalledWith(
+ 'Average order value',
+ expect.objectContaining({ fontSize: 24, fontWeight: 750 }),
+ )
+ const title = flatten(enlarged.nodes).find(
+ (node): node is SceneLabel =>
+ node.kind === 'label' && node.key === 'y-label',
+ )
+ if (!title) throw new Error('Expected a y-axis title')
+ const bounds = measureSceneLabelBounds(title, measureText)
+ expect(bounds.x).toBeGreaterThanOrEqual(3.99)
+ })
+
it('measures accessor-resolved tick-label bounds into automatic margins', () => {
const definition = (fontSize: number, dx: number) =>
defineChart({
diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts
index 5f465fd4..267bfef4 100644
--- a/packages/charts-core/src/scene.ts
+++ b/packages/charts-core/src/scene.ts
@@ -1501,9 +1501,12 @@ function createAxes(
children.push(candidate.label)
}
- const labelText = axisLabelText(presentation)
+ const axisLabel = presentation?.label
+ const labelText =
+ typeof axisLabel === 'string' ? axisLabel : axisLabel?.text
if (!labelText) return
- const labelOffset = axisLabelOffset(presentation)
+ const labelOptions = typeof axisLabel === 'object' ? axisLabel : undefined
+ const labelOffset = labelOptions?.offset ?? 'auto'
const explicitOffset = labelOffset !== 'auto'
const label: SceneLabel = {
kind: 'label',
@@ -1515,9 +1518,13 @@ function createAxes(
text: labelText,
anchor: 'middle',
baseline: bottom && !explicitOffset ? 'hanging' : 'auto',
- fontSize: width < 360 ? 10 : 11,
- fontWeight: 600,
- style: { fill: theme.foreground, fillOpacity: 0.76 },
+ fontSize: labelOptions?.fontSize ?? (width < 360 ? 10 : 11),
+ fontWeight: labelOptions?.fontWeight ?? 600,
+ style: {
+ fill: labelOptions?.fill ?? theme.foreground,
+ fillOpacity: labelOptions?.opacity === undefined ? 0.76 : undefined,
+ opacity: labelOptions?.opacity,
+ },
}
includeOutward(measureSceneLabelBounds(label, measureText))
children.push(label)
@@ -1594,8 +1601,11 @@ function createAxes(
children.push(candidate.label)
}
- const labelText = axisLabelText(presentation)
+ const axisLabel = presentation?.label
+ const labelText =
+ typeof axisLabel === 'string' ? axisLabel : axisLabel?.text
if (!labelText) return
+ const labelOptions = typeof axisLabel === 'object' ? axisLabel : undefined
const label: SceneLabel = {
kind: 'label',
key: `${guide.id}-label`,
@@ -1605,11 +1615,15 @@ function createAxes(
anchor: 'middle',
baseline: 'middle',
rotate: right ? 90 : -90,
- fontSize: 11,
- fontWeight: 600,
- style: { fill: theme.foreground, fillOpacity: 0.76 },
+ fontSize: labelOptions?.fontSize ?? 11,
+ fontWeight: labelOptions?.fontWeight ?? 600,
+ style: {
+ fill: labelOptions?.fill ?? theme.foreground,
+ fillOpacity: labelOptions?.opacity === undefined ? 0.76 : undefined,
+ opacity: labelOptions?.opacity,
+ },
}
- const labelOffset = axisLabelOffset(presentation)
+ const labelOffset = labelOptions?.offset ?? 'auto'
if (labelOffset !== 'auto') {
label.x = axisX + direction * Math.max(0, finiteMargin(labelOffset))
} else {
@@ -1676,20 +1690,6 @@ function tickLabelPresentation(
return axis?.tickLabels ?? {}
}
-function axisLabelText(
- axis: ChartAxisPresentationOptions | undefined,
-): string | undefined {
- return typeof axis?.label === 'string' ? axis.label : axis?.label?.text
-}
-
-function axisLabelOffset(
- axis: ChartAxisPresentationOptions | undefined,
-): number | 'auto' {
- return typeof axis?.label === 'object'
- ? (axis.label.offset ?? 'auto')
- : 'auto'
-}
-
interface TickLabelCandidate {
value: ChartValue
label: SceneLabel
diff --git a/packages/charts-core/src/svg-renderer.test.ts b/packages/charts-core/src/svg-renderer.test.ts
index af0d7c39..a3ecbb2d 100644
--- a/packages/charts-core/src/svg-renderer.test.ts
+++ b/packages/charts-core/src/svg-renderer.test.ts
@@ -1,4 +1,7 @@
+import { scaleLinear } from 'd3-scale'
import { describe, expect, it } from 'vitest'
+import { lineY } from './line'
+import { createChartScene, defineChart } from './scene'
import { renderChartSvg } from './svg'
import type { ChartScene } from './types'
@@ -13,6 +16,36 @@ describe('SVG scene renderer', () => {
)
expect(svg).not.toContain('M99,99Z')
})
+
+ it('serializes Cartesian axis-title typography and paint', () => {
+ const scene = createChartScene(
+ defineChart({
+ marks: [lineY([1, 2, 3])],
+ scales: {
+ x: { scale: scaleLinear().domain([0, 2]), axis: false },
+ y: {
+ scale: scaleLinear().domain([0, 3]),
+ axis: {
+ ticks: false,
+ label: {
+ text: 'Revenue',
+ fontSize: 17,
+ fontWeight: 650,
+ fill: '#0f766e',
+ opacity: 0.6,
+ },
+ },
+ },
+ },
+ }),
+ { width: 480, height: 260 },
+ )
+ const svg = renderChartSvg(scene, { ariaLabel: 'Revenue chart' })
+
+ expect(svg).toMatch(
+ /]* fill="#0f766e" opacity="0\.6"[^>]* font-size="17" font-weight="650"/,
+ )
+ })
})
function testScene(): ChartScene {
diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts
index 3ed32bb9..276d5a17 100644
--- a/packages/charts-core/src/types.ts
+++ b/packages/charts-core/src/types.ts
@@ -381,6 +381,11 @@ export interface ChartAxisTickLabelOptions {
export interface ChartAxisLabelOptions {
text: string
offset?: number | 'auto'
+ fontSize?: number
+ fontWeight?: number
+ /** Text fill color. Defaults to the theme foreground. */
+ fill?: string
+ opacity?: number
motion?: ChartMotionDefinition
}
diff --git a/packages/react-native-charts/src/SvgScene.test.tsx b/packages/react-native-charts/src/SvgScene.test.tsx
index 272eab7a..b50a67bb 100644
--- a/packages/react-native-charts/src/SvgScene.test.tsx
+++ b/packages/react-native-charts/src/SvgScene.test.tsx
@@ -1,6 +1,8 @@
import * as React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
+import { createChartScene, defineChart, lineY } from '@tanstack/charts'
+import { scaleLinear } from '@tanstack/charts/scales/linear'
import type { ChartScene, SceneNode } from '@tanstack/charts/types'
import { NativeChartFocusOverlay } from './FocusOverlay'
import { resolveNativePaint } from './paint'
@@ -70,6 +72,43 @@ describe('React Native SVG scene renderer', () => {
expect(markup).toContain('font-size="24"')
})
+ it('renders Cartesian axis-title typography and paint', () => {
+ const generated = createChartScene(
+ defineChart({
+ marks: [lineY([1, 2, 3])],
+ scales: {
+ x: { scale: scaleLinear().domain([0, 2]), axis: false },
+ y: {
+ scale: scaleLinear().domain([0, 3]),
+ axis: {
+ ticks: false,
+ label: {
+ text: 'Revenue',
+ fontSize: 17,
+ fontWeight: 650,
+ fill: '#0f766e',
+ opacity: 0.6,
+ },
+ },
+ },
+ },
+ }),
+ { width: 480, height: 260 },
+ )
+ const markup = renderToStaticMarkup(
+ ,
+ )
+
+ expect(markup).toMatch(
+ /]*fill="#0f766e"[^>]*opacity="0\.6"[^>]*font-size="17" font-weight="650"[^>]*>Revenue<\/text>/,
+ )
+ })
+
it('makes the two unsupported SVG joins an explicit lossy mapping', () => {
expect(resolveNativeLineJoin('arcs')).toBe('round')
expect(resolveNativeLineJoin('miter-clip')).toBe('miter')
diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs
index bb05b66a..f51c5ca9 100644
--- a/scripts/measure-bundles.mjs
+++ b/scripts/measure-bundles.mjs
@@ -696,7 +696,7 @@ const entries = [
budgeted(
'D3-scale areaX + static SVG',
'benchmarks/entries/charts-area-x-svg.ts',
- 24.25,
+ 24.28,
),
budgeted(
'D3-scale dots + static SVG',
@@ -761,6 +761,27 @@ const entries = [
},
},
),
+ incrementalBudgeted(
+ 'Axis title styles + static SVG',
+ 'benchmarks/entries/charts-axis-label-styles.ts',
+ 'D3-scale line + static SVG',
+ 0.25,
+ {
+ inputBoundary: {
+ forbid: [
+ 'focusGuide',
+ 'focusMark',
+ 'motionRuntime',
+ 'springRuntime',
+ 'tooltipRuntime',
+ 'tooltipPortal',
+ 'd3GeometryRuntime',
+ ],
+ addedFrom: 'D3-scale line + static SVG',
+ allowAdded: [],
+ },
+ },
+ ),
budgeted(
'Frame + static SVG',
'benchmarks/entries/charts-frame-svg.ts',
@@ -903,7 +924,7 @@ const entries = [
budgeted(
'Composite mark + static SVG',
'benchmarks/entries/charts-composite-mark.ts',
- 27.75,
+ 27.77,
{
inputBoundary: {
require: [
@@ -1072,7 +1093,7 @@ const entries = [
budgeted(
'D3-scale hexagons + static SVG',
'benchmarks/entries/charts-hexagon-svg.ts',
- 20.11,
+ 20.13,
{ inputBoundary: { forbid: ['d3Hexbin'] } },
),
incrementalBudgeted(
@@ -1102,7 +1123,7 @@ const entries = [
budgeted(
'D3-scale link + static SVG',
'benchmarks/entries/charts-link-svg.ts',
- 20.1,
+ 20.11,
{
inputBoundary: {
forbid: [
@@ -1198,7 +1219,7 @@ const entries = [
budgeted(
'Polar line + scatter composition + static SVG',
'benchmarks/entries/charts-polar-line-scatter-svg.ts',
- 26.8,
+ 26.81,
),
locked(
'Representative marks',
@@ -1661,7 +1682,7 @@ const entries = [
lockedBudgeted(
'React compact-scale line consumer',
'benchmarks/entries/charts-react-compact-line.ts',
- 28.47,
+ 28.5,
{
external: ['react', 'react/jsx-runtime', 'react-dom'],
rendererBoundary: 'svg',
@@ -1829,7 +1850,7 @@ const entries = [
budgeted(
'Stats parity surface',
'benchmarks/entries/charts-stats-parity.ts',
- 52.82,
+ 52.84,
),
locked(
'Custom-scale line scene',
@@ -1855,7 +1876,7 @@ const entries = [
budgeted(
'D3 curved line scene',
'benchmarks/entries/charts-d3-curved-line-scene.ts',
- 20.9,
+ 20.91,
),
budgeted(
'D3 time-scale line scene',
@@ -1865,12 +1886,12 @@ const entries = [
budgeted(
'Direct D3 monotone + TanStack SVG',
'benchmarks/entries/charts-d3-curve-svg.ts',
- 22.61,
+ 22.63,
),
budgeted(
'Direct D3 transforms + TanStack histogram',
'benchmarks/entries/charts-d3-transform-histogram.ts',
- 21.5,
+ 21.53,
),
budgeted(
'Direct D3 time + TanStack UTC line',
@@ -1880,12 +1901,12 @@ const entries = [
budgeted(
'Direct D3 quadtree + TanStack DOM host',
'benchmarks/entries/charts-d3-quadtree-dom.ts',
- 36.84,
+ 36.87,
),
budgeted(
'Direct D3 Delaunay + TanStack DOM host',
'benchmarks/entries/charts-d3-delaunay-dom.ts',
- 42.07,
+ 42.11,
),
measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'),
measured(
@@ -2089,7 +2110,7 @@ const entries = [
budgeted(
'React Stats parity surface',
'benchmarks/entries/charts-react-stats-parity.tsx',
- 53.72,
+ 53.74,
{ external: ['react', 'react/jsx-runtime', 'react-dom'] },
),
measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'),