Bar Chart
A canvas-rendered bar chart for comparing values across categories, with grouped and stacked variants.
When to use
Use a BarChart to compare values across discrete categories: months, endpoints, plans. Bars encode value by length from a shared zero baseline, so the y-axis minimum is fixed at 0 and a truncated-bar axis is unrepresentable.
- Time-binned aggregates (requests per day, cost per week) are categories too: each bar is a bucket's total, so a dense daily chart like the one below belongs here.
- Grouped bars (the default) compare series side by side within each category.
- Stacked bars (
stackedon the Root) show how parts compose each category's total. - Prefer
LineChartfor trends over continuous time: sampled measurements at every timestamp read as a stream, not buckets. - Prefer
AreaChartfor cumulative composition over continuous time.
Keep it to eight series or fewer. The default chart tokens are eight validated categorical slots. The ninth series onward all wear the neutral --color-chart-other, so a dozen access keys legend as a wall of identical gray. Eight is the palette's measured limit, not a style preference: all 28 pairs of the eight clear the colorblind-safe gates, and the closest pair clears its floor by 0.054 ΔE. A ninth hue has nowhere to sit. Rank by the window's total, keep the top seven, and sum the rest into one "Other" series. Series that belong to one thing are the narrow exception: they can share one seriesSlot and stay apart by texture. See Sharing one color slot.
Set the fold's own series to seriesSlot="other", so it always reads as the tail rather than taking a categorical slot. Keep it last, so it sits at the far end of a stack.
Compose everything inside BarChart.Root: gridlines, axes, one BarChart.Bar per series, and (on multi-series charts) the legend. The interaction layer (per-category hover band and tooltip, keyboard stepping, polite announcements, and an sr-only description) ships with Root unconditionally; the sr-only data table mounts once the chart takes focus.
Density takes care of itself: this chart plots 91 daily bars, and the band axis thins its own labels (evenly spaced from the first day, skipped rather than rotated when they'd collide). Date x values format as dates in the tooltip, land as exact ISO timestamps in the data-table twin, and take a tickFormat for short axis labels.
Grouped bars
The default layout draws one bar per series side by side within each category: the head-to-head comparison. Always compose BarChart.Legend on multi-series charts: series identity must never rely on color-matching alone.
Stacked bars
Add stacked to the Root to stack the composed series instead of grouping them. Bars stack from the baseline up in registration order: series that mount together register in composition order, so the first BarChart.Bar sits on the baseline and the last on top. A Bar that mounts later stacks on top of every series already registered, whatever its position in the JSX, and a dataKey that unmounts and returns resumes its original position. Reordering the parts of a mounted chart does not restack it: the position sticks to the dataKey, which is what keeps a series from jumping when you rename its label. The tooltip lists the far end of the stack first, so a vertical chart reads top-down and a horizontal one reads from the far end back to the baseline.
Sparse stacks
A stack whose series are zero on most columns is the shape a "usage by region" or "usage by access key" chart produces. Every geometry decision on a stacked bar is per column, not per series: the rounded cap goes to the segment that is actually outermost on that column, and the 2px surface gap is carved only where a neighbor adjoins. March carries one series, so its column sits flat on the axis and wears the cap on the segment a reader can see. June carries all three, so its seams are separated. Both orientations follow the same rule.
Segments smaller than the surface gap
A segment can be worth less than one pixel: a sub-cent line item against a dollar-scale axis. The gap carves at
most half of a segment's painted extent, so a short segment paints shorter rather than inverting or vanishing,
and a taller segment never paints shorter than the one beside it. A segment that rounds to no ink at all cannot
take the rounded cap from the segment a reader can see: March's events value is $0.004, so the column reads
the same as May's exact $0.
Diverging stacks
Negative values pile downward from the zero baseline while positive values pile up, and each pile caps its own
outer end. The axis grows below zero on its own: no yDomain override, because a bar chart's minimum is the
baseline, not the data floor. April is the month worth looking at: 62 endpoints added against 112 lost, so the
net is negative and the two piles are visibly unequal. Series that carry good or bad meaning wear the semantic
status colors here rather than categorical slots.
No usage yet
An all-zero series is data, not an empty state: it reports that nothing happened. The value axis stays anchored
at the baseline, so the zero line sits on the axis instead of floating through the middle of the plot. Keep the
accessible name, the tooltip, and the data table: a reader and a screen reader both need to hear "0", not
nothing. Reach for mode="decorative" only when the values are a synthetic backdrop behind an empty-state message; see
Decorative charts.
Regrouping a live chart
A dashboard that regroups one chart (by provider, then by region, then by access key) cycles a new set of
dataKeys through the same Root. Automatic series take the unreserved slots in composition order. Each grouping
therefore starts at slot 1 and paints four distinct validated colors.
Do not put a key on the Root to work around this. Remounting discards the tween state, the measured layout,
and the hover position, and it replays the enter animation on every switch. Cycle the series instead and let the
Root keep its store.
Removing an automatic series closes the gap, so a survivor may move to an earlier slot. When an entity must keep
the same built-in identity while sibling series appear or disappear, set seriesSlot.
Fixed colors for known entities
A chart of named things (AI model providers, cloud vendors, plan tiers) often wants one recognizable color per
thing across every chart and every page. Declare the values as custom properties on the Root and pass each one to
the matching series. The canvas resolves them from the Root's computed style, inside its own cascade. The legend swatch and the tooltip
stroke emit the same var(), so one declaration set covers all three surfaces.
Use var(--your-token) rather than a raw hex. A hex freezes: the same value paints on white and on near-black.
The dark theme cannot adjust it. A custom property lets you give each theme its own value, and mantle
re-resolves it whenever the theme changes.
The color prop changes only paint. It does not reserve a slot or change the default point shape. The example
also gives its known providers seriesSlot values, so automatic providers start at slot 4. When an entity needs
both an app-defined color and a fixed identity position, use this combination.
So check your own palette against the eight slots, and against itself. Series order decides whether a
collision bites, because a stacked chart only ever puts consecutive series against each other. The example below
runs OpenAI, Claude, Gemini, which clears both hard floors in both themes: 23.45 ΔE at worst for full-color
vision, and 6.85 under simulated color vision deficiency (CVD). That 6.85 clears the CVD floor of 6 and misses the
target of 8, which mantle's own palette treats as legal only with a second encoding, so the example gives Claude a
texture. Swap Claude and Gemini and Claude lands against chart-4 at 11.42 ΔE in dark, under the
full-color floor of 15. When a pair does collide, reorder the series, change the
color, or add a texture. When several entities belong to one family (zones of a region, tiers of one plan),
give them the same seriesSlot on purpose and keep them apart by texture. See
Sharing one color slot.
Three limits are worth knowing:
- Grouped bars and scatter plots compare every series to every other, not only its neighbors. The eight slots
clear that bar; this set does not. ChatGPT green sits 6.77 ΔE from dark's
chart-5#0da9ae. - Brand colors collide with each other. Five of the major AI providers use nearly the same blue.
- A black or near-black brand color falls below the chroma floor, so it carries no color identity at all.
A named entity that has no brand color needs no color at all. Leave both identity props unset to take the next
unreserved slot. When the entity must keep one built-in identity, set seriesSlot.
Horizontal bars
orientation names the direction the bars point, not an axis: "vertical" (the default) bars stand up, "horizontal" bars lie across. Set orientation="horizontal" to grow bars rightward from a left baseline with categories banded down the y axis. Reach for it when category labels are long (they read straight instead of angled or truncated under the axis) or when there are many categories to rank. xKey still names the category and YAxis still labels the value. Only the drawing flips: the category axis moves to the left gutter, the value axis to the bottom, and the baseline, reference lines, and the two rung textures ("perpendicular" and "parallel") all rotate to match. The "grid" lattice inks both rung directions, so it looks the same either way. Everything else (grouping, stacking, hover, keyboard, the data table) works unchanged.
Textures
When a multi-series or stacked chart can't rely on color alone (color-vision deficiencies, grayscale print and exports, forced-colors mode), give each series a fill texture. Textures are a redundant identity encoding alongside color: "hatch" (diagonal lines at 45°), "hatch-reverse" (its 135° mirror), "crosshatch" (both), "perpendicular" (rungs across the bar's length: horizontal lines on vertical bars), "parallel" (rungs along it: vertical lines on vertical bars), "grid" (both rung directions crossed), and "dots" (an offset dot field). The two rung textures flip with orientation, in opposite senses. Every other value draws the same whichever way the bars point, "grid" included: it is the orthogonal lattice against "crosshatch"'s diagonal one. Counting the default "solid", eight textures match the palette's eight color slots. The ink is tone-on-tone (a darker step of each series' own fill, at equal loudness across slots), and every legend key wears its series' pattern, so the mapping never depends on hue.
Texture is opt-in and purposeful, never decorative. Mantle assigns none of it for you. A line, area, or scatter series takes the point glyph paired to its series slot with no prop at all, and a bar carries no glyph, so the texture you set is a bar's only second channel. Keep solid fills when color alone already carries identity. When it does not, either leave the first series solid and texture the rest, or texture only the series in the pair that collides. The Diverging stacks and Fixed colors examples above take the targeted form.
Sharing one color slot
Several series can share one seriesSlot. The group reserves that slot once: the three zones assigned to slot 1 below still leave slot 2 for eu-west-1.
When several series belong to one thing (zones of a region, shards of a cluster, tiers of one plan), reach for it. Series that share no family belong in one "Other" series instead. Eight is a measured ceiling, not a style preference: the gates validate all 28 pairs of the eight tokens, so a ninth hue has nowhere to sit. Every pair clears, so grouped bars (which compare every series to every other) are as safe as a stack. A shared slot plus one texture per member encodes more series than the slots alone reach. It also reads as the family it is.
Two rules make it work.
- Every member sets the same
seriesSlot. Mantle reserves fixed slots before automatic series take the remaining slots. - Every member carries its own
texture. Mantle never readstexturewhen it hands out slots. Two series that share a token and a texture paint identically. Nothing warns you. One member may stay solid; the rest take a value each.
Count the costs before you reach for it. One hue no longer names one series, so the legend carries the mapping. Every member needs a label. A texture is a second encoding on a legal color, never a license to reuse an illegal one. A member that later drops seriesSlot takes the next unreserved slot.
Single series
A single-series chart draws every bar in one hue: the first categorical slot, --color-chart-1. One series means one color: never color bars by their own value, and never assign each category its own hue; that adds a false second encoding to data the bar lengths already show. There is no legend either; BarChart.Legend renders nothing for a single series because the chart's title already names it. Here the visible title carries the accessible name via aria-labelledby.
Requests by endpoint (last 24 hours)
Integer counts
Counts are integers, and the value axis knows it: when every series value is a whole number, ticks step by whole numbers. This chart's [0, 3] domain ticks at 0, 1, 2, 3 rather than inventing a meaningless "1.5 failures" gridline. Nothing to configure; the behavior follows the data, so a single fractional value (an average, a rate) restores sub-integer ticks. The same guarantee covers the value axis of horizontal bars and linear x axes on the other chart families.
Reference lines
Compose BarChart.ReferenceLine to mark a threshold, budget, or limit as a dashed horizontal line at a y value, with an optional label painted above its right end.
Custom tooltip
The tooltip is part of BarChart.Root's interaction contract and is always on: composing BarChart.Tooltip never adds or removes it, it only customizes the readout. Use labelFormat and valueFormat to format the rows, footer to append a hint below them, or the render-prop children to replace the content entirely.
Activation
onDatumActivate fires when a user clicks a bar or presses Enter or Space with the chart focused. The event carries { index, xValue, datum, dataKey }. On a stacked chart, dataKey names the segment the pointer sits in; otherwise it names the series nearest the pointer. Keyboard and whole-band activation set it to null. Click a bar (or Tab to the chart, arrow to a month, and press Enter) to update the readout below.
Nothing activated yet.
Activating a stacked segment
On a stacked chart dataKey names the segment the pointer sits in, never the boundary it sits near, because
the mark is the filled span. Click the lower third of a band and the readout still names that band. Resolving by
the nearest cumulative boundary instead would hand the baseline-side half of every segment to the series below
it.
Click a segment.
Controlled hover
Pass activeIndex with onActiveIndexChange to control the active category from outside the chart: pin a category from a linked table row, or sync hover across sibling charts. Omit activeIndex for uncontrolled hover.
Pending data
Set pending while a refetch is in flight: the chart holds the previous frame at reduced opacity instead of unmounting. Never swap a chart that has rendered once for a skeleton: the held frame keeps the axes, scales, and the user's place stable while fresh data loads.
Live data
Live values on a bar chart update in place: the categories are fixed, so bars never scroll. Each update morphs every bar from its current height to its new value with a per-datum tween, while the y axis glides to fit: the domain continuously chases its target and the value tick labels fade between tick sets instead of popping. prefers-reduced-motion snaps every update instantly. When the live dimension is time (a sliding window of appends), reach for a LineChart or AreaChart instead: bars at every timestamp read as categories, not a stream.
This demo recomputes all six regions every 400ms from a pure function of a tick counter (offset sine waves per region), so it renders deterministically; a real chart would replace data as new readings arrive.
Copy as markdown
BarChart.CopyButton copies the chart's current rows to the clipboard as a machine-stable, GitHub-flavored markdown table: the category column plus one column per series in paint order (plain un-separated numbers, an em dash for gaps). The table pastes into Slack, issues, spreadsheets, and LLM chats. The button renders in flow where composed; Root is relatively positioned, so className can dock it over a corner.
Lists of charts
A page of 100 charts pays for the handful a reader can see. A dormant BarChart.Root is a shell: the plot box, the named interaction overlay, and its screen-reader description. It holds no canvas, no hover layers, and no engine until it comes within one viewport of the visible area, takes focus, or prints; then it activates outside React, in slices of a few milliseconds that yield between them, on-screen charts first, and paints in the task that activates it. Three viewports away it releases its canvas memory and repaints on the way back. The tooltip surface mounts on the first hover, and the screen-reader data table waits for focus alone: it mounts once a reader tabs into the chart, so a page of sparklines holds no hidden tables until a reader asks for one. Add content-visibility: auto to your list's cards as a complement: it skips style and layout for offscreen cards, and the chart's own gate already skips paint and canvas memory.
The mount cost of a list is measured, not assumed. The chart mount benchmark renders a DataTable of 100 rows with two sparklines each; mode=list renders cards of full charts, and n, kind, and points set the shape. The Playwright driver in apps/www/bench/ loads the page and reports main-thread time, long tasks, and frames per case.
Modes
mode sets how much of the chart's contract a Root exposes. Pick by what a reader does with the chart.
Noninteractive is not decorative. A sparkline beside a total still tells a reader when activity happened and how it varied, so it keeps a name and a description; only its values live in the text beside it. One hundred sparklines in a table as one hundred Tab stops is a focus order no reader wants, and a readout that only a pointer can reach fails WCAG 1.4.13, so "static" offers neither. A chart that is the only source of its values stays "interactive".
Static charts
Set mode="static" for a chart a reader glances at while the exact values sit beside it. The plot is a named role="img": it takes the same aria-label or aria-labelledby an interactive chart does, and its aria-describedby points at the description, so a screen reader hears the chart kind, the category count, the first and last category, and the series labels without a Tab stop. There is no interaction overlay: no hover band, no tooltip, no keyboard point cursor, no data table, and no live region, and a composed BarChart.Tooltip has no effect. Pointer events pass through to the row or card behind it. The series keep their colors. animate defaults to false, because a list of sparklines that each play a reveal as the reader scrolls is motion with no information in it; pass animate to play the reveal. The interaction props (activeIndex, onActiveIndexChange, onDatumActivate) are forbidden at the type level. The Sparklines in a List recipe is the worked example.
Decorative charts
Set mode="decorative" to render the chart as a placeholder backdrop instead of real data: synthetic bars behind an empty state, an atmospheric hero graphic. A decorative chart keeps its visual rendering and animation but drops every layer that only makes sense for real data: it is hidden from assistive technology, removed from the tab order, and inert to pointer and keyboard, with no hover band, tooltip, sr-only data table, or live region. Because the values aren't information, mode="decorative" is mutually exclusive with the accessible-name props (aria-label / aria-labelledby) and the interaction props (activeIndex, onActiveIndexChange, onDatumActivate); the type won't compile with both.
A decorative chart also drops the categorical palette. Every series paints --color-chart-decorative, one neutral fill that outranks a series' color and seriesSlot. A backdrop carries no series identity, and a hue on values that are not information invites a reader to decode it.
That fill is what keeps an overlaid message readable. Empty.Description sits on text-muted, which measures 4.88:1 on a bare light card (0.38 over the 4.5:1 floor for 14px text), so any tint under that copy spends the headroom. --color-chart-decorative is the darkest neutral step that still clears the floor in both themes: 4.56:1 in light and 4.93:1 in dark. The message needs no scrim behind it, no dimming, and no blur. Redeclare the token on the Root to retune the fill (className="[--color-chart-decorative:var(--color-neutral-100)]") and re-measure the copy against the value you pick.
Reach for mode="decorative" over hand-rolled inert + CSS: it removes the interaction overlay at the source, so consumers never target chart internals to neutralize it. Keep the real empty-state message as a sibling of the chart (not inside it) so screen readers still announce it; the decorative chart itself is silent.
mode="decorative" says the values are not information; it is not a disabled flag (a chart is not a form control). For real data a reader only glances at, use mode="static".
No usage yet
Traffic will appear here once your endpoints start receiving requests.
Composition
Compose the parts of a BarChart together to build your own:
Accessibility
Every interactive chart must have an accessible name: either a direct aria-label or an aria-labelledby reference to a visible title. This is enforced at the type level: BarChart.Root does not compile without exactly one of the two. Prefer aria-labelledby when the chart sits under a visible heading, so the name never drifts from the title. The name lands on the focusable interaction overlay, the chart's single named element (the canvas itself is hidden from assistive technology so the chart is never announced twice). A static chart names its plot role="img" instead and has no Tab stop. A decorative chart carries no real data, so it takes no accessible name and is hidden from assistive technology entirely.
The chart is a single Tab stop with a full keyboard model:
- Left / Right arrows step between categories.
- PageUp / PageDown jump in larger strides (about a tenth of the data).
- Home / End jump to the first and last category.
- Escape clears the active category.
- Enter / Space activate the current category (fires
onDatumActivate).
Keyboard stepping announces the category and each series' value through a debounced aria-live="polite" region, so rapid arrowing doesn't flood the screen-reader queue.
The overlay's aria-describedby points at a screen-reader description, data-slot="bar-chart-description", so a screen reader hears what the chart holds when it takes focus: the chart kind, the number of categories, the first and last category, the series labels, and the keyboard instructions. For example: "Bar chart with 12 categories from Jan to Dec. Series: Desktop, Mobile. Use the left and right arrow keys to move between data points, …". An empty chart reads "Bar chart with no data." in place of the count and range. Server HTML carries the plain count alone. The range waits for hydration, because Intl output differs by locale, and the series labels register in layout effects, so both render on the client.
BarChart.Root mounts dormant and renders its sr-only data table (the category column plus one column per series) once it engages: once focus lands inside it (the overlay is the Tab stop, and a focusable part composed inside the Root, such as BarChart.CopyButton, counts too). It never goes dormant again. Visibility paints the chart but never mounts the table. The table is bounded to the first 150 rows, with a caption stating the summarization when the data is longer. The Root stamps data-state="dormant", then data-state="engaged", and the table mounts with the flip, so a screen reader's table list names only the charts the reader has focused. The description above names what every chart holds, focused or not. Tooltips enhance, they never gate: every plotted value is reachable through the table or the keyboard point cursor, and no part removal can strip keyboard access to the data.
The table doubles as the chart's machine-readable surface: value cells carry the raw number in data-value (and date row headers, where present, the exact ISO timestamp in <time dateTime>), so agents and scrapers read precise data from the DOM without reverse-engineering locale formatting. Assistive tech still hears the human-formatted text, and the table itself is addressable at data-slot="bar-chart-data-table". To reach it, an agent focuses the chart (a scroll into view is not enough), then waits for data-state="engaged" on the Root. BarChart.CopyButton hands over the same rows as markdown with neither step.
API Reference
BarChart.Bar, BarChart.Grid, BarChart.XAxis, BarChart.YAxis, and BarChart.ReferenceLine are renderless: they register configuration with the Root, which paints everything on canvas. They accept no className or other DOM props. BarChart.Tooltip and BarChart.Legend accept div props — the tooltip forwards them to the readout element the Root owns, and the legend renders real DOM in flow below the plot. BarChart.CopyButton also renders real DOM — an icon button accepting button props.
BarChart.Root
The root of a bar chart: owns the data, the scales, the canvas renderer, and the entire interaction contract. Renders a <div> sized aspect-video at full width by default — override with className. Bars encode value by length from a shared zero baseline, so the y domain minimum is fixed at 0.
All props from div, plus:
Data attributes
BarChart.Bar
One bar series. Renders nothing itself — it registers the series with the chart, which paints it on canvas. Compose one Bar per series; with stacked on the Root, bars stack from the baseline up in registration order. Accepts no className or DOM props.
Bar thickness follows the category spacing; there is no barSize prop. A bar fills its slot up to a cap of 60% of one category's step, floored at 24px and ceilinged at 64px. Few categories therefore grow the bars rather than the air between them — seven days across a 93px step paint ~56px bars, not 24px bars in 69px of air — while a dense chart keeps filling its slot exactly as before. Grouped series split the band and each bar fills its own slot, so a group tightens instead of outgrowing its category. The rule reads the step alone, so the same data at the same width always paints the same pixels, and thickness only ever rises as categories thin out.
BarChart.Grid
Hairline value gridlines behind the bars (renderless; painted on canvas). The default runs perpendicular to the bars: horizontal lines for vertical bars, vertical lines for horizontal bars. Pass lines to force a direction. Omit the part to omit the grid. Accepts no className or DOM props.
BarChart.XAxis
Category labels for the bars (renderless; painted on canvas): along the bottom for vertical bars, down the left gutter for horizontal bars. Labels that would collide are skipped, never rotated. Accepts no className or DOM props.
BarChart.YAxis
Value tick labels (renderless; painted on canvas): along the left for vertical bars, across the bottom for horizontal bars. Ticks land on clean thousands-separated numbers. Accepts no className or DOM props.
BarChart.ReferenceLine
A dashed marker at the value y, such as an SLO threshold, a budget, or a limit (renderless; painted on canvas). It crosses the value axis: a horizontal line for vertical bars, a vertical line for horizontal bars. Accepts no className or DOM props.
BarChart.Tooltip
Customizes the hover/keyboard tooltip readout. The tooltip itself is part of BarChart.Root's interaction contract and renders even when this part is absent — compose it to format the label or values, append a footer, or replace the content entirely.
A children readout renders inside a <div data-slot="bar-chart-tooltip-label">. When it goes away, React removes that div instead of your own text nodes: a browser translation engine reparents text nodes, and a removal aimed at one throws. The div is display: contents, so it adds no box and your content stays a direct layout child of the parent. To style it as a box, set a display of your own on it first. Browser translation covers the mechanism and the rules your own components need.
The HoverSnapshot passed to footer and children describes the active category: { index, xValue, datum, points, viaKeyboard }, where points lists every series' value at the active index. Stacked, the rows run from the far end of the pile back to the baseline; unstacked, they follow registration order.
All props from div (forwarded to the tooltip element the Root owns) except children, plus:
Data attributes
BarChart.Legend
The legend: series names keyed by color swatches, rendered as real DOM in flow below the plot. Always compose it on multi-series charts — identity must never rely on color-matching alone. It renders nothing for a single series (the chart's title already names it). The legend is at most 64px tall (max-h-16) and scrolls past that, so many series never take the plot's height in a fixed-height Root. Set a max-h-* class in className to change the bound.
A children render prop renders inside a <div data-slot="bar-chart-legend-label">. When it goes away, React removes that div instead of your own text nodes: a browser translation engine reparents text nodes, and a removal aimed at one throws. The div is display: contents, so it adds no box and your content stays a direct layout child of the parent. To style it as a box, set a display of your own on it first. Browser translation covers the mechanism and the rules your own components need.
All props from div except children, plus:
Data attributes
BarChart.CopyButton
A button that copies the chart's current data to the clipboard as a GitHub-flavored markdown table: the category column plus one column per series in paint order. Values are machine-stable — plain un-separated numbers, an em dash for gaps — so the paste target receives parseable data, not locale formatting. Renders an icon button in flow where composed; Root is relatively positioned, so className can dock it over a corner (e.g. absolute right-0 top-0).
All props from button, plus:
While a copy is fresh, an always-mounted role="status" element beside the button announces "Copied" to a screen reader.
Chart color tokens
Series colors default to the ordered --color-chart-1 through --color-chart-8 tokens. Each of the two themes (light and dark) authors its own eight oklch() literals, measured against mantle's palette validation gates: all 28 pairs hold 15 ΔE apart for full-color vision and 8 ΔE under simulated protanopia or deuteranopia, and every slot clears 3:1 against that theme's card surface.
Series receive unreserved slots in composition order. Set seriesSlot to give a series a fixed visual identity. Mantle reserves fixed slots before automatic series take the remaining slots. Several related series may share one. seriesSlot="other" selects the neutral tail treatment without reserving a categorical slot. Removing an automatic series closes the gap. The color and shape props override only their own channels and never change slot assignment. When the chart runs out of unreserved categorical slots, later automatic series paint with --color-chart-other. Prefer folding the long tail into a single "Other" series over plotting nine-plus indistinguishable ones.
What each theme measures across its eight slots:
The color prop on BarChart.Bar accepts any CSS color string as an escape hatch, but a static color (raw hex) does not adapt across themes — custom palettes must be theme-aware, and validated for surface contrast and for colorblind-safe separation from every other color on the chart, just like the built-in tokens. A series that means good or bad — error rate, pass/fail — should wear the semantic status colors (e.g. var(--color-danger-500)), never a categorical slot.
Series identity and color
Three public paths control a series identity or color.
- Fixed identity — pass
seriesSlotonBarChart.Bar. It reserves one built-in color identity before automatic series take the remaining slots. - One color channel — pass
coloronBarChart.Bar. It takes a chart token by name or any CSS color string without changing slot assignment. - One chart — redeclare the token on
BarChart.Root:className="[--color-chart-1:var(--color-brand)]". Every series that lands in slot 1 picks the new value up. So do its legend swatch and tooltip stroke.
The chart resolves every token from Root's computed style in one read, inside that Root's own cascade, so a declaration scoped to one Root wins for that chart alone. Sibling charts on the page keep the theme value. A bare currentColor resolves from Root's computed color. A value the canvas cannot parse by itself, such as light-dark() or a currentColor nested inside color-mix(), resolves through a hidden element inside Root instead. Declare the same custom property higher up (on a dashboard wrapper, or at :root) to retheme a whole subtree.
Both paths want a var() reference rather than a raw hex, for the reason Fixed colors for known entities gives. An overridden slot leaves mantle's gates behind, so the measurement is yours. Grouped bars compare every series to every other, so measure your color against every slot the chart hands out — not only the two beside it. The #d07c00 below clears 8 ΔE from chart-2 under simulated protanopia and deuteranopia in both themes, and 3.19:1 on the white card surface.