Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/soft-legends-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/charts': minor
---

Add tree-shakeable, host-measured categorical legend item layouts with
configurable spacing, typography, label paint, built-in indicator shapes, and
custom indicator scene renderers.
24 changes: 23 additions & 1 deletion API-FRICTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 | Categorical legend styling required a replacement renderer | API | resolved |

## Findings

Expand Down Expand Up @@ -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 - Categorical legend styling required a replacement renderer

- Status: resolved
- Severity: medium
- Owner: API
- Observed in: replacing Rewardo's line, bar, mixed, and pie chart legends
- Friction: changing label typography, series-colored labels, spacing, or
indicators required a complete `ChartColorLegend`. The application repeated
color-domain resolution, estimated text widths, wrapped rows, centered each
row, and reserved legend height itself.
- Decision: let `colorLegend()` accept a `colorLegendItems()` presentation for
categorical labels and indicators while retaining chart-owned measurement
and wrapping. The separate factory keeps its machinery out of default legend
bundles. Keep the equal-column layout as the default. Compact start and center
layouts use the chart host's configured text measurer. Indicator callbacks
receive resolved color-scale items and a measured bounds box.
- Verification: focused layout, measurement, renderer, type, and mixed-mark
tests cover compact wrapping, per-series symbols, resolved label paint, and
custom scene indicators. The composed and pie catalog examples use the
built-in legend instead of application-owned layout.
8 changes: 8 additions & 0 deletions benchmarks/bundle-size/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ The interactive categorical legend fixture adds 2.53 KiB gzip over the DOM
host and has a 2.6 KiB incremental cap. Both retain only their declared exact
subpath modules.

The static categorical legend fixtures measure the default legend separately
from the tree-shakeable `colorLegendItems()` presentation. The configured
fixture covers centered flow layout, custom label paint, and mixed square and
line-dot indicators through the exact `@tanstack/charts/legend` subpath. Their
retained-input gates reject the interactive legend, host renderers,
interactions, and D3 geometry. The default fixture has a 1.8 KiB gzip ceiling;
item presentation may add at most 1.15 KiB gzip.

Update the universal baseline only after reviewing why a shared path changed:

```sh
Expand Down
40 changes: 20 additions & 20 deletions benchmarks/bundle-size/universal-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": 50840,
"gzip": 19076
},
"D3-scale line + static SVG": {
"bytes": 55544,
"gzip": 20776
"bytes": 55571,
"gzip": 20782
},
"Representative marks": {
"bytes": 75617,
"gzip": 27582
"bytes": 75644,
"gzip": 27587
},
"TanStack DOM host": {
"bytes": 74947,
"gzip": 26053
"bytes": 74974,
"gzip": 26058
},
"React adapter": {
"bytes": 77130,
"gzip": 26831
"bytes": 77157,
"gzip": 26837
},
"React line consumer": {
"bytes": 100766,
"gzip": 36222
"bytes": 100793,
"gzip": 36228
},
"Compact-scale line scene": {
"bytes": 33265,
"gzip": 11935
"bytes": 33292,
"gzip": 11941
},
"React compact-scale line consumer": {
"bytes": 83266,
"gzip": 29144
"bytes": 83293,
"gzip": 29150
},
"Custom-scale line scene": {
"bytes": 31447,
"gzip": 11193
"bytes": 31474,
"gzip": 11200
},
"D3 linear-scale line scene": {
"bytes": 50745,
"gzip": 19032
"bytes": 50772,
"gzip": 19039
}
}
}
128 changes: 64 additions & 64 deletions benchmarks/comparison/bundle-baseline.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"schemaVersion": 4,
"generatedAt": "2026-08-26T21:51:12.832Z",
"generatedAt": "2026-08-27T22:42:28.689Z",
"packageVersions": {
"tanstack": "0.15.0",
"tanstack": "0.16.0",
"chartjs": "4.5.1",
"echarts": "6.1.0",
"recharts": "3.10.1",
Expand All @@ -11,8 +11,8 @@
"sources": {
"tanstack": {
"kind": "workspace",
"revision": "3df87d71f0305e5a450c10b66940f76f3e14259a",
"inputDigest": "sha256:b1016e599e63d05e1647b9d27adabaaed7a38bb44dac8d6ba0fef06b8b8bd38e"
"revision": "2ddcf661897ba305e81b83f80dd9c802bea2307d",
"inputDigest": "sha256:d4aaf3d3e5bf3586a218124ce822e6a0484f6ea9e041c806d47380be9b936db2"
},
"chartjs": {
"kind": "package",
Expand Down Expand Up @@ -45,88 +45,88 @@
},
"bundles": {
"tanstack-line-basic": {
"minifiedBytes": 110431,
"gzipBytes": 39891,
"brotliBytes": 35325,
"incrementalGzipBytes": 39891,
"incrementalBrotliBytes": 35325
"minifiedBytes": 110458,
"gzipBytes": 39896,
"brotliBytes": 35340,
"incrementalGzipBytes": 39896,
"incrementalBrotliBytes": 35340
},
"tanstack-line-interactive": {
"minifiedBytes": 115872,
"gzipBytes": 41648,
"brotliBytes": 36746,
"incrementalGzipBytes": 41648,
"incrementalBrotliBytes": 36746
"minifiedBytes": 116019,
"gzipBytes": 41709,
"brotliBytes": 36809,
"incrementalGzipBytes": 41709,
"incrementalBrotliBytes": 36809
},
"tanstack-line-advanced": {
"minifiedBytes": 123062,
"gzipBytes": 43974,
"brotliBytes": 38764,
"incrementalGzipBytes": 43974,
"incrementalBrotliBytes": 38764
"minifiedBytes": 123209,
"gzipBytes": 44037,
"brotliBytes": 38748,
"incrementalGzipBytes": 44037,
"incrementalBrotliBytes": 38748
},
"tanstack-bar-basic": {
"minifiedBytes": 119225,
"gzipBytes": 43210,
"brotliBytes": 38168,
"incrementalGzipBytes": 43210,
"incrementalBrotliBytes": 38168
"minifiedBytes": 119252,
"gzipBytes": 43216,
"brotliBytes": 38134,
"incrementalGzipBytes": 43216,
"incrementalBrotliBytes": 38134
},
"tanstack-bar-interactive": {
"minifiedBytes": 123521,
"gzipBytes": 44585,
"brotliBytes": 39223,
"incrementalGzipBytes": 44585,
"incrementalBrotliBytes": 39223
"minifiedBytes": 123668,
"gzipBytes": 44643,
"brotliBytes": 39213,
"incrementalGzipBytes": 44643,
"incrementalBrotliBytes": 39213
},
"tanstack-bar-advanced": {
"minifiedBytes": 123860,
"gzipBytes": 44735,
"brotliBytes": 39349,
"incrementalGzipBytes": 44735,
"incrementalBrotliBytes": 39349
"minifiedBytes": 124007,
"gzipBytes": 44794,
"brotliBytes": 39345,
"incrementalGzipBytes": 44794,
"incrementalBrotliBytes": 39345
},
"tanstack-area-basic": {
"minifiedBytes": 115640,
"gzipBytes": 41867,
"brotliBytes": 37073,
"incrementalGzipBytes": 41867,
"incrementalBrotliBytes": 37073
"minifiedBytes": 115667,
"gzipBytes": 41871,
"brotliBytes": 37074,
"incrementalGzipBytes": 41871,
"incrementalBrotliBytes": 37074
},
"tanstack-area-interactive": {
"minifiedBytes": 121085,
"gzipBytes": 43594,
"brotliBytes": 38493,
"incrementalGzipBytes": 43594,
"incrementalBrotliBytes": 38493
"minifiedBytes": 121232,
"gzipBytes": 43648,
"brotliBytes": 38510,
"incrementalGzipBytes": 43648,
"incrementalBrotliBytes": 38510
},
"tanstack-area-advanced": {
"minifiedBytes": 128457,
"gzipBytes": 46057,
"brotliBytes": 40527,
"incrementalGzipBytes": 46057,
"incrementalBrotliBytes": 40527
"minifiedBytes": 128604,
"gzipBytes": 46114,
"brotliBytes": 40644,
"incrementalGzipBytes": 46114,
"incrementalBrotliBytes": 40644
},
"tanstack-scatter-basic": {
"minifiedBytes": 111460,
"gzipBytes": 40266,
"brotliBytes": 35596,
"incrementalGzipBytes": 40266,
"incrementalBrotliBytes": 35596
"minifiedBytes": 111487,
"gzipBytes": 40271,
"brotliBytes": 35668,
"incrementalGzipBytes": 40271,
"incrementalBrotliBytes": 35668
},
"tanstack-scatter-interactive": {
"minifiedBytes": 116901,
"gzipBytes": 42015,
"brotliBytes": 36976,
"incrementalGzipBytes": 42015,
"incrementalBrotliBytes": 36976
"minifiedBytes": 117044,
"gzipBytes": 42075,
"brotliBytes": 37041,
"incrementalGzipBytes": 42075,
"incrementalBrotliBytes": 37041
},
"tanstack-scatter-advanced": {
"minifiedBytes": 116917,
"gzipBytes": 42020,
"brotliBytes": 37012,
"incrementalGzipBytes": 42020,
"incrementalBrotliBytes": 37012
"minifiedBytes": 117060,
"gzipBytes": 42081,
"brotliBytes": 37123,
"incrementalGzipBytes": 42081,
"incrementalBrotliBytes": 37123
},
"chartjs-line-basic": {
"minifiedBytes": 137909,
Expand Down
66 changes: 46 additions & 20 deletions benchmarks/conformance/cases/168-shadcn-pie-legend/example.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import { defineChart, type ChartPoint } from '@tanstack/charts'
import {
colorLegend,
colorLegendItems,
defineChart,
type ChartPoint,
} from '@tanstack/charts'
import { focusGroupAngle, pie, polar, radialArc } from '@tanstack/charts/polar'
import { RendererChart } from '@tanstack/charts/react/tooltip'
import { tooltip } from '@tanstack/charts/tooltip'
import { motion } from '@tanstack/charts/motion'
import { shadcnBrowsers, shadcnColors } from '@tanstack/charts-data/shadcn'
import './styles.css'
const browserNames = shadcnBrowsers.map((row) => row.browser)
const visuallyHidden = {
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
} as const

export function createExampleChart() {
const arcs = pie(shadcnBrowsers, {
value: 'visitors',
Expand Down Expand Up @@ -40,7 +57,29 @@ export function createExampleChart() {
x: null,
y: null,
},
color: { domain: browserNames, range: shadcnColors },
color: {
domain: browserNames,
range: shadcnColors,
legend: colorLegend<string>({
placement: 'bottom',
items: colorLegendItems({
justify: 'center',
gap: 12,
rowGap: 8,
indicator: {
shape: 'square',
width: 12,
height: 12,
gap: 6,
},
label: {
format: titleCase,
fontSize: 12,
fill: (_browser, { color }) => color,
},
}),
}),
},
margin: 0,
},
{
Expand Down Expand Up @@ -121,26 +160,13 @@ export default function Example({ width = 640, height = 600 }: ExampleProps) {
ariaLabel="Pie Chart - Legend"
/>
</div>
<div className="sc-chart-footer">
<Legend />
</div>
<ul aria-label="Browser legend" style={visuallyHidden}>
{browserNames.map((browser) => (
<li key={browser}>{titleCase(browser)}</li>
))}
</ul>
</div>
</article>
</div>
)
}
function Legend() {
return (
<>
{['chrome', 'safari', 'firefox', 'edge', 'other'].map((label, index) => (
<span className="sc-legend-item" key={label}>
<span
className="sc-legend-dot"
style={{ background: shadcnColors[index] }}
/>
{titleCase(label)}
</span>
))}
</>
)
}
Loading