diff --git a/.changeset/focus-ring-options.md b/.changeset/focus-ring-options.md
new file mode 100644
index 00000000..a2597a29
--- /dev/null
+++ b/.changeset/focus-ring-options.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/charts': minor
+---
+
+Allow the built-in focus ring to configure its radius, stroke width, fill, and stroke while preserving the existing boolean behavior and defaults.
diff --git a/API-FRICTION.md b/API-FRICTION.md
index 5204435b..c0791442 100644
--- a/API-FRICTION.md
+++ b/API-FRICTION.md
@@ -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 | Focus ring paint required generated SVG selectors | API | resolved |
## Findings
@@ -8505,3 +8506,24 @@ 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 - Focus ring paint required generated SVG selectors
+
+- Status: resolved
+- Severity: medium
+- Owner: API
+- Observed in: Rewardo's TanStack Charts migration and issue #94
+- Friction: the built-in primary-point ring exposed only a boolean definition
+ option. Rewardo had to target `.ts-chart__focus-layer--default circle` to
+ reduce the fixed 5-pixel radius, tying application styling to generated SVG
+ structure and leaving Canvas and React Native without the same control.
+- Decision: extend `focusRing` to accept a reusable `ChartFocusRingOptions`
+ object with radius, stroke width, fill, and stroke. Preserve `true`, `false`,
+ and every existing default. An omitted stroke keeps each point's resolved
+ series color.
+- Verification: scene, SVG, Canvas, and React Native tests cover configured
+ geometry and paint, series-color fallback, keyboard focus, and hidden
+ accessibility presentation. The catalog's pointer-tooltip case uses the
+ object form, and the reference docs record the complete contract. Locked
+ universal consumers add 65-79 minified bytes and 25-36 gzip bytes; the
+ reviewed bundle baselines and budgets pass.
diff --git a/benchmarks/bundle-size/universal-baseline.json b/benchmarks/bundle-size/universal-baseline.json
index 486ae8f9..3981a2ce 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": 50878,
+ "gzip": 19095
},
"D3-scale line + static SVG": {
- "bytes": 55544,
- "gzip": 20776
+ "bytes": 55612,
+ "gzip": 20808
},
"Representative marks": {
- "bytes": 75617,
- "gzip": 27582
+ "bytes": 75683,
+ "gzip": 27615
},
"TanStack DOM host": {
- "bytes": 74947,
- "gzip": 26053
+ "bytes": 75026,
+ "gzip": 26087
},
"React adapter": {
- "bytes": 77130,
- "gzip": 26831
+ "bytes": 77209,
+ "gzip": 26866
},
"React line consumer": {
- "bytes": 100766,
- "gzip": 36222
+ "bytes": 100839,
+ "gzip": 36251
},
"Compact-scale line scene": {
- "bytes": 33265,
- "gzip": 11935
+ "bytes": 33336,
+ "gzip": 11969
},
"React compact-scale line consumer": {
- "bytes": 83266,
- "gzip": 29144
+ "bytes": 83345,
+ "gzip": 29173
},
"Custom-scale line scene": {
- "bytes": 31447,
- "gzip": 11193
+ "bytes": 31518,
+ "gzip": 11229
},
"D3 linear-scale line scene": {
- "bytes": 50745,
- "gzip": 19032
+ "bytes": 50810,
+ "gzip": 19057
}
}
}
diff --git a/benchmarks/comparison/bundle-baseline.json b/benchmarks/comparison/bundle-baseline.json
index a22a54c1..b457f5f6 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-27T22:10:29.425Z",
"packageVersions": {
- "tanstack": "0.15.0",
+ "tanstack": "0.16.0",
"chartjs": "4.5.1",
"echarts": "6.1.0",
"recharts": "3.10.1",
@@ -11,8 +11,8 @@
"sources": {
"tanstack": {
"kind": "workspace",
- "revision": "3df87d71f0305e5a450c10b66940f76f3e14259a",
- "inputDigest": "sha256:b1016e599e63d05e1647b9d27adabaaed7a38bb44dac8d6ba0fef06b8b8bd38e"
+ "revision": "cb1779249a6803d591526f7f4e42a20a40e11f4f",
+ "inputDigest": "sha256:f269a4edd2c1aea574be0de98bc6dff87ad5044501cb4d0a65c4cb565ad4c2ae"
},
"chartjs": {
"kind": "package",
@@ -45,88 +45,88 @@
},
"bundles": {
"tanstack-line-basic": {
- "minifiedBytes": 110431,
- "gzipBytes": 39891,
- "brotliBytes": 35325,
- "incrementalGzipBytes": 39891,
- "incrementalBrotliBytes": 35325
+ "minifiedBytes": 110507,
+ "gzipBytes": 39915,
+ "brotliBytes": 35396,
+ "incrementalGzipBytes": 39915,
+ "incrementalBrotliBytes": 35396
},
"tanstack-line-interactive": {
- "minifiedBytes": 115872,
- "gzipBytes": 41648,
- "brotliBytes": 36746,
- "incrementalGzipBytes": 41648,
- "incrementalBrotliBytes": 36746
+ "minifiedBytes": 115948,
+ "gzipBytes": 41682,
+ "brotliBytes": 36752,
+ "incrementalGzipBytes": 41682,
+ "incrementalBrotliBytes": 36752
},
"tanstack-line-advanced": {
- "minifiedBytes": 123062,
- "gzipBytes": 43974,
- "brotliBytes": 38764,
- "incrementalGzipBytes": 43974,
- "incrementalBrotliBytes": 38764
+ "minifiedBytes": 123138,
+ "gzipBytes": 43996,
+ "brotliBytes": 38742,
+ "incrementalGzipBytes": 43996,
+ "incrementalBrotliBytes": 38742
},
"tanstack-bar-basic": {
- "minifiedBytes": 119225,
- "gzipBytes": 43210,
- "brotliBytes": 38168,
- "incrementalGzipBytes": 43210,
- "incrementalBrotliBytes": 38168
+ "minifiedBytes": 119300,
+ "gzipBytes": 43240,
+ "brotliBytes": 38149,
+ "incrementalGzipBytes": 43240,
+ "incrementalBrotliBytes": 38149
},
"tanstack-bar-interactive": {
- "minifiedBytes": 123521,
- "gzipBytes": 44585,
- "brotliBytes": 39223,
- "incrementalGzipBytes": 44585,
- "incrementalBrotliBytes": 39223
+ "minifiedBytes": 123596,
+ "gzipBytes": 44613,
+ "brotliBytes": 39219,
+ "incrementalGzipBytes": 44613,
+ "incrementalBrotliBytes": 39219
},
"tanstack-bar-advanced": {
- "minifiedBytes": 123860,
- "gzipBytes": 44735,
- "brotliBytes": 39349,
- "incrementalGzipBytes": 44735,
- "incrementalBrotliBytes": 39349
+ "minifiedBytes": 123935,
+ "gzipBytes": 44758,
+ "brotliBytes": 39385,
+ "incrementalGzipBytes": 44758,
+ "incrementalBrotliBytes": 39385
},
"tanstack-area-basic": {
- "minifiedBytes": 115640,
- "gzipBytes": 41867,
- "brotliBytes": 37073,
- "incrementalGzipBytes": 41867,
- "incrementalBrotliBytes": 37073
+ "minifiedBytes": 115714,
+ "gzipBytes": 41897,
+ "brotliBytes": 37085,
+ "incrementalGzipBytes": 41897,
+ "incrementalBrotliBytes": 37085
},
"tanstack-area-interactive": {
- "minifiedBytes": 121085,
- "gzipBytes": 43594,
- "brotliBytes": 38493,
- "incrementalGzipBytes": 43594,
- "incrementalBrotliBytes": 38493
+ "minifiedBytes": 121159,
+ "gzipBytes": 43630,
+ "brotliBytes": 38508,
+ "incrementalGzipBytes": 43630,
+ "incrementalBrotliBytes": 38508
},
"tanstack-area-advanced": {
- "minifiedBytes": 128457,
- "gzipBytes": 46057,
- "brotliBytes": 40527,
- "incrementalGzipBytes": 46057,
- "incrementalBrotliBytes": 40527
+ "minifiedBytes": 128530,
+ "gzipBytes": 46084,
+ "brotliBytes": 40565,
+ "incrementalGzipBytes": 46084,
+ "incrementalBrotliBytes": 40565
},
"tanstack-scatter-basic": {
- "minifiedBytes": 111460,
- "gzipBytes": 40266,
- "brotliBytes": 35596,
- "incrementalGzipBytes": 40266,
- "incrementalBrotliBytes": 35596
+ "minifiedBytes": 111536,
+ "gzipBytes": 40299,
+ "brotliBytes": 35718,
+ "incrementalGzipBytes": 40299,
+ "incrementalBrotliBytes": 35718
},
"tanstack-scatter-interactive": {
- "minifiedBytes": 116901,
- "gzipBytes": 42015,
- "brotliBytes": 36976,
- "incrementalGzipBytes": 42015,
- "incrementalBrotliBytes": 36976
+ "minifiedBytes": 116977,
+ "gzipBytes": 42045,
+ "brotliBytes": 36987,
+ "incrementalGzipBytes": 42045,
+ "incrementalBrotliBytes": 36987
},
"tanstack-scatter-advanced": {
- "minifiedBytes": 116917,
- "gzipBytes": 42020,
- "brotliBytes": 37012,
- "incrementalGzipBytes": 42020,
- "incrementalBrotliBytes": 37012
+ "minifiedBytes": 116993,
+ "gzipBytes": 42050,
+ "brotliBytes": 37016,
+ "incrementalGzipBytes": 42050,
+ "incrementalBrotliBytes": 37016
},
"chartjs-line-basic": {
"minifiedBytes": 137909,
diff --git a/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx b/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx
index 319b366e..8d989259 100644
--- a/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx
+++ b/benchmarks/conformance/cases/34-pointer-tooltip/example.tsx
@@ -69,6 +69,11 @@ export function createExampleChart({ revision = 0 }: ChartOptions = {}) {
},
},
{
+ focusRing: {
+ radius: 4,
+ strokeWidth: 2,
+ fill: '#ffffff',
+ },
keyboard: true,
tooltip: { use: tooltip, ...interactiveTooltip },
},
diff --git a/benchmarks/conformance/previews/34-pointer-tooltip.svg b/benchmarks/conformance/previews/34-pointer-tooltip.svg
index fdb6d046..4bc5eb3a 100644
--- a/benchmarks/conformance/previews/34-pointer-tooltip.svg
+++ b/benchmarks/conformance/previews/34-pointer-tooltip.svg
@@ -1 +1 @@
-
+
diff --git a/benchmarks/conformance/previews/manifest.json b/benchmarks/conformance/previews/manifest.json
index e1176205..68a5e224 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": "19e1c1412fb763649cbf1fa03b7c99cf5b6e1319fca7c9657a2add18e2aac322",
"assets": [
{
"id": "01-line-gaps",
@@ -166,8 +166,8 @@
},
{
"id": "34-pointer-tooltip",
- "sha256": "e4ff132c905893b271d67984435b50620b267447c8081c2ac9f96295d99482e5",
- "bytes": 15814
+ "sha256": "dd7351252b3594da5e6fe0ac0b3a7bc0e748764fd588cd2c1432fd2ff1f9b218",
+ "bytes": 15780
},
{
"id": "35-grouped-tooltip",
diff --git a/docs/comparison.md b/docs/comparison.md
index 912adf20..cd477364 100644
--- a/docs/comparison.md
+++ b/docs/comparison.md
@@ -12,14 +12,14 @@ turning untested behavior into a checkmark.
| Library | Package | Measured source |
| -------------------------------------------------------------------------------------- | -------------------- | ------------------- |
-| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `3df87d7` |
+| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `cb17792` |
| [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` |
| [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` |
| [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` |
| [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` |
The competitor versions are exact package pins, not latest versions inferred
-at page render time. The measured TanStack workspace revision is `3df87d7`.
+at page render time. The measured TanStack workspace revision is `cb17792`.
## Capability matrix
@@ -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.98–45.00 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/examples/interactive-charts.md b/docs/examples/interactive-charts.md
index 62f3b914..9a3bef96 100644
--- a/docs/examples/interactive-charts.md
+++ b/docs/examples/interactive-charts.md
@@ -49,6 +49,21 @@ dot(rows, {
})
```
+The same definition can make the built-in indicator smaller while retaining
+its series-colored outline:
+
+```ts
+const definition = defineChart({
+ marks,
+ scales,
+ focusRing: {
+ radius: 4,
+ strokeWidth: 2,
+ fill: '#ffffff',
+ },
+})
+```
+
A focused band emphasizes the shared x value for every series. Its position
diff --git a/docs/reference/chart-definitions.md b/docs/reference/chart-definitions.md
index 74996619..c18c84fe 100644
--- a/docs/reference/chart-definitions.md
+++ b/docs/reference/chart-definitions.md
@@ -103,6 +103,11 @@ does not return or own them.
belong to both static and responsive definitions. Hosts and framework adapters
do not override them.
+`focusRing` accepts `true`, `false`, or a `ChartFocusRingOptions` object with
+`radius`, `strokeWidth`, `fill`, and `stroke`. Omitted fields keep the built-in
+defaults, including the focused point's resolved color for `stroke`. See
+[Focus and interaction](./focus-and-interaction.md#built-in-focus-ring).
+
Each `ChartControl` resolves after final scales and plot bounds exist. It can
provide renderer-neutral fallback nodes and an optional host control. Control
IDs and host-control identities must be unique. Browser hosts remove a
diff --git a/docs/reference/dom-host.md b/docs/reference/dom-host.md
index 85633106..e094e536 100644
--- a/docs/reference/dom-host.md
+++ b/docs/reference/dom-host.md
@@ -116,7 +116,7 @@ The definition owns these chart controls:
| ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `maxFocusDistance` | `48` | Maximum scene-pixel distance for default pointer focus |
| `focus` | Nearest point | Pointer grouping and keyboard navigation strategy; `false` disables chart-owned focus and its generated layer |
-| `focusRing` | `true` | Generated primary-point focus indicator; `false` keeps authored focus layers only |
+| `focusRing` | `true` | Generated primary-point indicator; an options object styles it and `false` keeps authored focus layers only |
| `cursor` | None | Focus-snapped or free application-owned cursor binding |
| `spatialIndex` | Linear nearest-point scan | Dense-data nearest-point index |
| `svgAnimation` | `false` | Keyed attribute, enter, and exit animation |
diff --git a/docs/reference/focus-and-interaction.md b/docs/reference/focus-and-interaction.md
index 96ddf62d..9ba1695a 100644
--- a/docs/reference/focus-and-interaction.md
+++ b/docs/reference/focus-and-interaction.md
@@ -36,6 +36,37 @@ Authored `whenFocused` marks compose with the primary-point ring. Set
definition `focusRing: false` only when authored focus geometry replaces that
indicator.
+### Built-in focus ring
+
+Pass an options object to change the built-in ring without targeting generated
+SVG:
+
+```ts
+const definition = defineChart({
+ marks,
+ scales,
+ focusRing: {
+ radius: 4,
+ strokeWidth: 2,
+ fill: '#ffffff',
+ },
+})
+```
+
+| Option | Default |
+| ------------- | ----------------------------------------- |
+| `radius` | `5` |
+| `strokeWidth` | `2.5` |
+| `fill` | `var(--ts-chart-focus-fill, Canvas)` |
+| `stroke` | The focused point's resolved series color |
+
+The object form still enables the ring. `true` keeps every default and `false`
+removes only the built-in ring. The definition compiles this presentation into
+ordinary scene dots, so SVG, Canvas, React Native, facets, polar marks, custom
+mark renderers, and the optional motion renderer share the same values. The
+ring remains hidden from the accessibility tree; the chart root continues to
+report focus and values.
+
Use `focusGuideX` or `focusGuideY` from `@tanstack/charts/focus/guide` for
datum-bound rules, markers, and axis labels. Their active geometry uses stable
structural keys, so the optional motion renderer can animate rapid focus
diff --git a/packages/charts-core/docs/comparison.md b/packages/charts-core/docs/comparison.md
index 912adf20..cd477364 100644
--- a/packages/charts-core/docs/comparison.md
+++ b/packages/charts-core/docs/comparison.md
@@ -12,14 +12,14 @@ turning untested behavior into a checkmark.
| Library | Package | Measured source |
| -------------------------------------------------------------------------------------- | -------------------- | ------------------- |
-| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `3df87d7` |
+| [TanStack Charts](./overview.md) | `@tanstack/charts` | workspace `cb17792` |
| [Chart.js](https://www.chartjs.org/docs/latest/) | `chart.js` | npm `4.5.1` |
| [Apache ECharts](https://echarts.apache.org/handbook/en/best-practices/canvas-vs-svg/) | `echarts` | npm `6.1.0` |
| [Recharts](https://recharts.github.io/en-US/) | `recharts` | npm `3.10.1` |
| [Observable Plot](https://observablehq.com/plot/features/plots) | `@observablehq/plot` | npm `0.6.17` |
The competitor versions are exact package pins, not latest versions inferred
-at page render time. The measured TanStack workspace revision is `3df87d7`.
+at page render time. The measured TanStack workspace revision is `cb17792`.
## Capability matrix
@@ -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.98–45.00 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/examples/interactive-charts.md b/packages/charts-core/docs/examples/interactive-charts.md
index 62f3b914..9a3bef96 100644
--- a/packages/charts-core/docs/examples/interactive-charts.md
+++ b/packages/charts-core/docs/examples/interactive-charts.md
@@ -49,6 +49,21 @@ dot(rows, {
})
```
+The same definition can make the built-in indicator smaller while retaining
+its series-colored outline:
+
+```ts
+const definition = defineChart({
+ marks,
+ scales,
+ focusRing: {
+ radius: 4,
+ strokeWidth: 2,
+ fill: '#ffffff',
+ },
+})
+```
+
A focused band emphasizes the shared x value for every series. Its position
diff --git a/packages/charts-core/docs/reference/chart-definitions.md b/packages/charts-core/docs/reference/chart-definitions.md
index 74996619..c18c84fe 100644
--- a/packages/charts-core/docs/reference/chart-definitions.md
+++ b/packages/charts-core/docs/reference/chart-definitions.md
@@ -103,6 +103,11 @@ does not return or own them.
belong to both static and responsive definitions. Hosts and framework adapters
do not override them.
+`focusRing` accepts `true`, `false`, or a `ChartFocusRingOptions` object with
+`radius`, `strokeWidth`, `fill`, and `stroke`. Omitted fields keep the built-in
+defaults, including the focused point's resolved color for `stroke`. See
+[Focus and interaction](./focus-and-interaction.md#built-in-focus-ring).
+
Each `ChartControl` resolves after final scales and plot bounds exist. It can
provide renderer-neutral fallback nodes and an optional host control. Control
IDs and host-control identities must be unique. Browser hosts remove a
diff --git a/packages/charts-core/docs/reference/dom-host.md b/packages/charts-core/docs/reference/dom-host.md
index 85633106..e094e536 100644
--- a/packages/charts-core/docs/reference/dom-host.md
+++ b/packages/charts-core/docs/reference/dom-host.md
@@ -116,7 +116,7 @@ The definition owns these chart controls:
| ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `maxFocusDistance` | `48` | Maximum scene-pixel distance for default pointer focus |
| `focus` | Nearest point | Pointer grouping and keyboard navigation strategy; `false` disables chart-owned focus and its generated layer |
-| `focusRing` | `true` | Generated primary-point focus indicator; `false` keeps authored focus layers only |
+| `focusRing` | `true` | Generated primary-point indicator; an options object styles it and `false` keeps authored focus layers only |
| `cursor` | None | Focus-snapped or free application-owned cursor binding |
| `spatialIndex` | Linear nearest-point scan | Dense-data nearest-point index |
| `svgAnimation` | `false` | Keyed attribute, enter, and exit animation |
diff --git a/packages/charts-core/docs/reference/focus-and-interaction.md b/packages/charts-core/docs/reference/focus-and-interaction.md
index 96ddf62d..9ba1695a 100644
--- a/packages/charts-core/docs/reference/focus-and-interaction.md
+++ b/packages/charts-core/docs/reference/focus-and-interaction.md
@@ -36,6 +36,37 @@ Authored `whenFocused` marks compose with the primary-point ring. Set
definition `focusRing: false` only when authored focus geometry replaces that
indicator.
+### Built-in focus ring
+
+Pass an options object to change the built-in ring without targeting generated
+SVG:
+
+```ts
+const definition = defineChart({
+ marks,
+ scales,
+ focusRing: {
+ radius: 4,
+ strokeWidth: 2,
+ fill: '#ffffff',
+ },
+})
+```
+
+| Option | Default |
+| ------------- | ----------------------------------------- |
+| `radius` | `5` |
+| `strokeWidth` | `2.5` |
+| `fill` | `var(--ts-chart-focus-fill, Canvas)` |
+| `stroke` | The focused point's resolved series color |
+
+The object form still enables the ring. `true` keeps every default and `false`
+removes only the built-in ring. The definition compiles this presentation into
+ordinary scene dots, so SVG, Canvas, React Native, facets, polar marks, custom
+mark renderers, and the optional motion renderer share the same values. The
+ring remains hidden from the accessibility tree; the chart root continues to
+report focus and values.
+
Use `focusGuideX` or `focusGuideY` from `@tanstack/charts/focus/guide` for
datum-bound rules, markers, and axis labels. Their active geometry uses stable
structural keys, so the optional motion renderer can animate rapid focus
diff --git a/packages/charts-core/src/canvas.test.ts b/packages/charts-core/src/canvas.test.ts
index 5ce7d73c..88561a1f 100644
--- a/packages/charts-core/src/canvas.test.ts
+++ b/packages/charts-core/src/canvas.test.ts
@@ -988,6 +988,51 @@ describe('Canvas renderer', () => {
surface.destroy()
})
+ it('paints configured focus-ring geometry and paint on Canvas', () => {
+ const chart = createChartScene(
+ defineChart({
+ marks: [dot([{ x: 1, y: 2 }], { x: 'x', y: 'y' })],
+ scales: {
+ x: { scale: scaleLinear().domain([0, 2]) },
+ y: { scale: scaleLinear().domain([0, 4]) },
+ },
+ guides: false,
+ focusRing: {
+ radius: 4,
+ strokeWidth: 1.5,
+ fill: '#ffffff',
+ stroke: '#0f172a',
+ },
+ }),
+ { width: 200, height: 120 },
+ )
+ const point = chart.points[0]
+ if (!point) throw new Error('Expected a focus point')
+ const container = document.createElement('div')
+ document.body.append(container)
+ const surface = createCanvasChartRenderer().mount(container, () => {})
+ surface.render(chart, renderOptions())
+ const focus = contexts.get(surface.focusCanvas)
+ if (!focus) throw new Error('Expected a Canvas focus layer')
+
+ surface.paintFocus({
+ primary: point,
+ group: [point],
+ source: 'pointer',
+ pinned: false,
+ })
+
+ expect(focus.operations).toContain(`arc:${point.x},${point.y},4`)
+ expect(focus.operations).toEqual(
+ expect.arrayContaining([expect.stringMatching(/^stroke:.*:1\.5:1$/)]),
+ )
+ expect(focus.operations).toEqual(
+ expect.arrayContaining([expect.stringMatching(/^fill:.*(?:ffffff|255)/)]),
+ )
+ surface.destroy()
+ container.remove()
+ })
+
it('snaps retargeting focus candidates without adding interaction points', () => {
const container = document.createElement('div')
const surface = createCanvasChartRenderer().mount(container, () => {})
@@ -2466,14 +2511,16 @@ function fakeContext(): FakeCanvasContext {
operations.push(`translate:${values.join(',')}`),
rotate: (value: number) => operations.push(`rotate:${value}`),
clip: () => operations.push('clip'),
- fill: (pathOrRule?: Path2D | CanvasFillRule) =>
+ fill: (pathOrRule?: Path2D | CanvasFillRule) => {
operations.push(
pathOrRule === 'evenodd'
? 'fill:evenodd'
: pathOrRule
? 'fill:path'
: 'fill:current',
- ),
+ )
+ operations.push(`fill:${String(fillStyle)}:${globalAlpha}`)
+ },
stroke: (path?: Path2D) => {
operations.push(path ? 'stroke:path' : 'stroke:current')
operations.push(
diff --git a/packages/charts-core/src/index.ts b/packages/charts-core/src/index.ts
index 192eb680..f8b24647 100644
--- a/packages/charts-core/src/index.ts
+++ b/packages/charts-core/src/index.ts
@@ -336,6 +336,7 @@ export type {
ChartFocusAnchor,
ChartFocusCursorBinding,
ChartFocusPresentation,
+ ChartFocusRingOptions,
ChartFocusMode,
ChartFocusFilter,
ChartFocusAffinity,
diff --git a/packages/charts-core/src/scene.test.ts b/packages/charts-core/src/scene.test.ts
index 77158588..1e1a9817 100644
--- a/packages/charts-core/src/scene.test.ts
+++ b/packages/charts-core/src/scene.test.ts
@@ -173,6 +173,119 @@ describe('native mark and channel scene', () => {
)
})
+ it('configures the built-in focus ring while retaining point-color defaults', () => {
+ const rows = [
+ { id: 'alpha', series: 'Alpha', x: 0, y: 1 },
+ { id: 'beta', series: 'Beta', x: 1, y: 2 },
+ ]
+ const spec = {
+ marks: [
+ lineY(rows, {
+ id: 'series',
+ x: 'x',
+ y: 'y',
+ z: 'series',
+ key: 'id',
+ }),
+ ],
+ color: {
+ domain: ['Alpha', 'Beta'],
+ range: ['#2563eb', '#f97316'],
+ },
+ theme: { background: '#f8fafc', foreground: '#0f172a' },
+ ...linearAxes([0, 1], [0, 2]),
+ } as const
+ const scene = createChartScene(
+ defineChart({
+ ...spec,
+ focusRing: {
+ radius: 4,
+ strokeWidth: 1.5,
+ fill: '#ffffff',
+ },
+ }),
+ { width: 320, height: 180 },
+ )
+ const focusDots = flatten(scene.nodes).filter(
+ (node) =>
+ node.kind === 'dot' &&
+ scene.points.some((point) => point.key === node.key) &&
+ node.radius === 4,
+ )
+
+ expect(scene.theme).toMatchObject({
+ background: '#f8fafc',
+ foreground: '#0f172a',
+ })
+ expect(focusDots.map((node) => node.style)).toEqual([
+ { fill: '#ffffff', stroke: '#2563eb', strokeWidth: 1.5 },
+ { fill: '#ffffff', stroke: '#f97316', strokeWidth: 1.5 },
+ ])
+
+ const fixedStroke = createChartScene(
+ defineChart({
+ ...spec,
+ focusRing: {
+ radius: 3,
+ strokeWidth: 2,
+ fill: '#ffffff',
+ stroke: '#0f172a',
+ },
+ }),
+ { width: 320, height: 180 },
+ )
+ const fixedDots = flatten(fixedStroke.nodes).filter(
+ (node) => node.kind === 'dot' && node.radius === 3,
+ )
+
+ expect(fixedDots.map((node) => node.style)).toEqual([
+ { fill: '#ffffff', stroke: '#0f172a', strokeWidth: 2 },
+ { fill: '#ffffff', stroke: '#0f172a', strokeWidth: 2 },
+ ])
+
+ const booleanRing = createChartScene(
+ defineChart({ ...spec, focusRing: true }),
+ { width: 320, height: 180 },
+ )
+ const booleanDots = flatten(booleanRing.nodes).filter(
+ (node) => node.kind === 'dot' && node.radius === 5,
+ )
+
+ expect(booleanDots.map((node) => node.style)).toEqual([
+ {
+ fill: 'var(--ts-chart-focus-fill, Canvas)',
+ stroke: '#2563eb',
+ strokeWidth: 2.5,
+ },
+ {
+ fill: 'var(--ts-chart-focus-fill, Canvas)',
+ stroke: '#f97316',
+ strokeWidth: 2.5,
+ },
+ ])
+
+ const undefinedFields = createChartScene(
+ defineChart({
+ ...spec,
+ focusRing: {
+ radius: undefined,
+ fill: undefined,
+ stroke: undefined,
+ strokeWidth: undefined,
+ },
+ }),
+ { width: 320, height: 180 },
+ )
+ const undefinedDots = flatten(undefinedFields.nodes).filter(
+ (node) => node.kind === 'dot' && node.radius === 5,
+ )
+
+ expect(undefinedDots.map((node) => node.style)).toEqual(
+ booleanDots.map((node) => node.style),
+ )
+ expectTypeOf(scene).toMatchTypeOf>()
+ })
+
it('rejects duplicate control ids', () => {
const behavior = { id: 'duplicate', resolve: () => ({}) }
diff --git a/packages/charts-core/src/scene.ts b/packages/charts-core/src/scene.ts
index 5f465fd4..f84b2053 100644
--- a/packages/charts-core/src/scene.ts
+++ b/packages/charts-core/src/scene.ts
@@ -27,6 +27,7 @@ import type {
ResponsiveChartConfig,
ChartColorLegend,
ChartFocusFilter,
+ ChartFocusRingOptions,
ChartLayoutOptions,
ChartMargin,
ChartMark,
@@ -529,6 +530,12 @@ function createChartSceneWithScaleResolver<
definition.focusRing !== false &&
points.length
) {
+ const {
+ radius = 5,
+ fill = 'var(--ts-chart-focus-fill, Canvas)',
+ stroke,
+ strokeWidth = 2.5,
+ } = (definition.focusRing || {}) as ChartFocusRingOptions
for (const entry of defaultFocusEntries) {
nodes.push({
kind: 'group',
@@ -547,11 +554,11 @@ function createChartSceneWithScaleResolver<
key: point.key,
x: point.x,
y: point.y,
- radius: 5,
+ radius,
style: {
- fill: 'var(--ts-chart-focus-fill, Canvas)',
- stroke: point.color,
- strokeWidth: 2.5,
+ fill,
+ stroke: stroke ?? point.color,
+ strokeWidth,
},
})),
})
diff --git a/packages/charts-core/src/svg-surface.test.ts b/packages/charts-core/src/svg-surface.test.ts
index 0f23946f..847e9f3d 100644
--- a/packages/charts-core/src/svg-surface.test.ts
+++ b/packages/charts-core/src/svg-surface.test.ts
@@ -9,6 +9,62 @@ import { renderChartSvgWithResources } from './svg-resources'
import { createSvgChartRenderer, svgChartRenderer } from './svg-surface'
describe('SVG surface coordinates', () => {
+ it('paints a configured focus ring without exposing it to accessibility', () => {
+ const scene = createChartScene(
+ defineChart({
+ marks: [
+ lineY([{ x: 1, y: 2 }], {
+ id: 'trend',
+ x: 'x',
+ y: 'y',
+ }),
+ ],
+ scales: {
+ x: { scale: scaleLinear().domain([0, 2]) },
+ y: { scale: scaleLinear().domain([0, 4]) },
+ },
+ guides: false,
+ focusRing: {
+ radius: 4,
+ strokeWidth: 1.5,
+ fill: '#ffffff',
+ stroke: '#0f172a',
+ },
+ }),
+ { width: 200, height: 120 },
+ )
+ const point = scene.points[0]
+ if (!point) throw new Error('Expected a focus point')
+ const container = document.createElement('div')
+ const surface = svgChartRenderer.mount(container, () => {})
+ surface.render(scene, { ariaLabel: 'Configured focus ring' })
+ const svg = container.querySelector('svg')
+ const layer = container.querySelector(
+ '.ts-chart__focus-layer--default',
+ )
+ const ring = layer?.querySelector('circle')
+
+ expect(svg?.getAttribute('role')).toBe('img')
+ expect(svg?.getAttribute('aria-label')).toBe('Configured focus ring')
+ expect(layer?.getAttribute('aria-hidden')).toBe('true')
+ expect(layer?.getAttribute('visibility')).toBe('hidden')
+ expect(ring?.getAttribute('r')).toBe('4')
+ expect(ring?.getAttribute('fill')).toBe('#ffffff')
+ expect(ring?.getAttribute('stroke')).toBe('#0f172a')
+ expect(ring?.getAttribute('stroke-width')).toBe('1.5')
+
+ surface.paintFocus({
+ primary: point,
+ group: [point],
+ source: 'keyboard',
+ pinned: false,
+ })
+
+ expect(layer?.getAttribute('visibility')).toBe('visible')
+ expect(ring?.getAttribute('visibility')).toBe('visible')
+ surface.destroy()
+ })
+
it('mounts viewport content with the fixed authored plot clip', () => {
const scene = createChartScene(
defineChart({
diff --git a/packages/charts-core/src/types.ts b/packages/charts-core/src/types.ts
index 3ed32bb9..a708ea5b 100644
--- a/packages/charts-core/src/types.ts
+++ b/packages/charts-core/src/types.ts
@@ -581,6 +581,15 @@ export interface ChartTheme {
palette: readonly string[]
}
+/** Paint and geometry for the built-in primary-point focus indicator. */
+export interface ChartFocusRingOptions {
+ radius?: number
+ fill?: string
+ /** Defaults to the focused point's resolved color. */
+ stroke?: string
+ strokeWidth?: number
+}
+
export interface ChartGradientStop {
offset: number
color: string
@@ -879,8 +888,8 @@ export interface ChartDefinitionOptions<
> {
maxFocusDistance?: number
focus?: ChartFocusMode, NoInfer, NoInfer>
- /** Shows the built-in primary-point focus ring. Defaults to true. */
- focusRing?: boolean
+ /** Shows and optionally styles the built-in primary-point focus ring. Defaults to true. */
+ focusRing?: boolean | ChartFocusRingOptions
/** Optional app-owned cursor shared by one or more chart definitions. */
cursor?: ChartCursorBinding<
NoInfer,
@@ -913,7 +922,7 @@ export interface ChartDefinitionOptions<
interface StoredChartDefinitionOptions {
maxFocusDistance?: number
focus?: ChartFocusMode
- focusRing?: boolean
+ focusRing?: boolean | ChartFocusRingOptions
cursor?: ChartCursorBinding
spatialIndex?: ChartSpatialIndexFactory
svgAnimation?: boolean | ChartAnimationOptions
diff --git a/packages/charts-core/src/universal-types.ts b/packages/charts-core/src/universal-types.ts
index 3aef282c..8aed7dbe 100644
--- a/packages/charts-core/src/universal-types.ts
+++ b/packages/charts-core/src/universal-types.ts
@@ -225,6 +225,7 @@ export type {
ChartFocusAnchor,
ChartFocusCursorBinding,
ChartFocusPresentation,
+ ChartFocusRingOptions,
ChartFocusFilter,
ChartFocusAffinity,
ChartFocusGroupContext,
diff --git a/packages/react-native-charts/src/Chart.test.tsx b/packages/react-native-charts/src/Chart.test.tsx
index 4c62d636..31385a8e 100644
--- a/packages/react-native-charts/src/Chart.test.tsx
+++ b/packages/react-native-charts/src/Chart.test.tsx
@@ -878,6 +878,54 @@ describe('React Native Chart', () => {
}
})
+ it('paints configured focus-ring geometry and paint in native SVG', async () => {
+ const configuredDefinition = defineChart({
+ marks: [lineY(data, { x: 'month', y: 'value' })],
+ scales: {
+ x: { scale: scaleLinear().domain([1, 2]) },
+ y: { scale: scaleLinear().domain([8, 12]) },
+ },
+ guides: false,
+ focusRing: {
+ radius: 4,
+ strokeWidth: 1.5,
+ fill: '#ffffff',
+ stroke: '#0f172a',
+ },
+ })
+ const container = document.createElement('div')
+ const root = createRoot(container)
+
+ try {
+ await React.act(() => {
+ root.render(
+ ,
+ )
+ })
+ await React.act(() => {
+ container.firstElementChild?.dispatchEvent(
+ new FocusEvent('focusin', { bubbles: true }),
+ )
+ })
+
+ const ring = container.querySelector('circle')
+ expect(ring?.getAttribute('r')).toBe('4')
+ expect(ring?.getAttribute('fill')).toBe('#ffffff')
+ expect(ring?.getAttribute('stroke')).toBe('#0f172a')
+ expect(ring?.getAttribute('stroke-width')).toBe('1.5')
+ expect(
+ container.firstElementChild?.getAttribute('data-accessibility-role'),
+ ).toBe('adjustable')
+ } finally {
+ await React.act(() => root.unmount())
+ }
+ })
+
it('renders a clipped two-axis crosshair, marker, labels, and focus fill', async () => {
const crosshairDefinition = defineChart({
marks: [
diff --git a/scripts/measure-bundles.mjs b/scripts/measure-bundles.mjs
index bb05b66a..cd6aae51 100644
--- a/scripts/measure-bundles.mjs
+++ b/scripts/measure-bundles.mjs
@@ -671,7 +671,7 @@ const entries = [
budgeted(
'D3-scale lineX + static SVG',
'benchmarks/entries/charts-line-x-svg.ts',
- 20.24,
+ 20.26,
),
budgeted(
'D3-scale UTC line + static SVG',
@@ -681,7 +681,7 @@ const entries = [
budgeted(
'D3-scale histogram + static SVG',
'benchmarks/entries/charts-histogram-svg.ts',
- 22.78,
+ 22.79,
),
budgeted(
'D3-scale facets + static SVG',
@@ -691,17 +691,17 @@ const entries = [
budgeted(
'D3-scale arrows + static SVG',
'benchmarks/entries/charts-arrow-svg.ts',
- 20.2,
+ 20.22,
),
budgeted(
'D3-scale areaX + static SVG',
'benchmarks/entries/charts-area-x-svg.ts',
- 24.25,
+ 24.28,
),
budgeted(
'D3-scale dots + static SVG',
'benchmarks/entries/charts-dot-svg.ts',
- 20.67,
+ 20.68,
{
inputBoundary: {
forbid: [
@@ -764,7 +764,7 @@ const entries = [
budgeted(
'Frame + static SVG',
'benchmarks/entries/charts-frame-svg.ts',
- 11.63,
+ 11.64,
),
incrementalBudgeted(
'Spatial density contours + static SVG',
@@ -903,7 +903,7 @@ const entries = [
budgeted(
'Composite mark + static SVG',
'benchmarks/entries/charts-composite-mark.ts',
- 27.75,
+ 27.79,
{
inputBoundary: {
require: [
@@ -943,7 +943,7 @@ const entries = [
'Linear regression + static SVG',
'benchmarks/entries/charts-regression-svg.ts',
'D3-scale line + static SVG',
- 6.59,
+ 6.6,
{
inputBoundary: {
require: [
@@ -1072,7 +1072,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 +1102,7 @@ const entries = [
budgeted(
'D3-scale link + static SVG',
'benchmarks/entries/charts-link-svg.ts',
- 20.1,
+ 20.12,
{
inputBoundary: {
forbid: [
@@ -1142,22 +1142,22 @@ const entries = [
budgeted(
'D3-scale ticks + static SVG',
'benchmarks/entries/charts-tick-svg.ts',
- 21.13,
+ 21.14,
),
budgeted(
'D3-scale vectors + static SVG',
'benchmarks/entries/charts-vector-svg.ts',
- 20.31,
+ 20.32,
),
budgeted(
'D3 geo shape + static SVG',
'benchmarks/entries/charts-geo-svg.ts',
- 17.58,
+ 17.59,
),
budgeted(
'Polar arc + static SVG',
'benchmarks/entries/charts-polar-arc-svg.ts',
- 16.56,
+ 16.58,
{ inputBoundary: { forbid: ['polarPie'] } },
),
incrementalBudgeted(
@@ -1176,7 +1176,7 @@ const entries = [
budgeted(
'Polar gauge composition + static SVG',
'benchmarks/entries/charts-polar-gauge-svg.ts',
- 25.69,
+ 25.7,
),
budgeted(
'Radial labels + static SVG',
@@ -1187,7 +1187,7 @@ const entries = [
budgeted(
'Polar radial bars + static SVG',
'benchmarks/entries/charts-radial-bar-svg.ts',
- 25.7,
+ 25.71,
{
inputBoundary: {
require: ['polarMarks', 'd3ScaleRuntime', 'd3Shape'],
@@ -1198,7 +1198,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',
@@ -1642,7 +1642,7 @@ const entries = [
lockedBudgeted(
'Compact-scale line scene',
'benchmarks/entries/charts-compact-linear-scene.ts',
- 11.68,
+ 11.69,
{
inputBoundary: {
require: ['compactLinear'],
@@ -1661,7 +1661,7 @@ const entries = [
lockedBudgeted(
'React compact-scale line consumer',
'benchmarks/entries/charts-react-compact-line.ts',
- 28.47,
+ 28.49,
{
external: ['react', 'react/jsx-runtime', 'react-dom'],
rendererBoundary: 'svg',
@@ -1829,7 +1829,7 @@ const entries = [
budgeted(
'Stats parity surface',
'benchmarks/entries/charts-stats-parity.ts',
- 52.82,
+ 52.86,
),
locked(
'Custom-scale line scene',
@@ -1855,7 +1855,7 @@ const entries = [
budgeted(
'D3 curved line scene',
'benchmarks/entries/charts-d3-curved-line-scene.ts',
- 20.9,
+ 20.92,
),
budgeted(
'D3 time-scale line scene',
@@ -1880,12 +1880,12 @@ const entries = [
budgeted(
'Direct D3 quadtree + TanStack DOM host',
'benchmarks/entries/charts-d3-quadtree-dom.ts',
- 36.84,
+ 36.86,
),
budgeted(
'Direct D3 Delaunay + TanStack DOM host',
'benchmarks/entries/charts-d3-delaunay-dom.ts',
- 42.07,
+ 42.09,
),
measured('D3 array numeric kernel', 'benchmarks/entries/d3-array-kernel.ts'),
measured(
@@ -2089,7 +2089,7 @@ const entries = [
budgeted(
'React Stats parity surface',
'benchmarks/entries/charts-react-stats-parity.tsx',
- 53.72,
+ 53.75,
{ external: ['react', 'react/jsx-runtime', 'react-dom'] },
),
measured('Plot renderer integration', 'benchmarks/entries/plot-renderer.ts'),