A chart that looks right on a white card often falls apart on a dark one. Lines that were crisp turn muddy, the lightest cell of a heatmap glows brighter than the darkest, and two series that differed only in hue become hard to tell apart. Nexera's charts handle dark mode with three things: tokens that change per mode, a heatmap ramp that runs in the other direction, and marks that never rely on colour alone.
Series colours change steps
Charts draw with eight series tokens, chart-1 to chart-8, plus chart-axis and chart-grid. Components use them as classes such as stroke-chart-1 and fill-chart-3, never as hex values, so they follow the mode and any custom theme.
In light mode, the series tokens point at the darker middle of each colour scale. In dark mode, they point at lighter steps:
chart-1isbrand-600in light andbrand-400in dark.chart-2isblue-600in light andblue-400in dark.chart-3isamber-600in light andamber-400in dark.chart-5iscyan-700in light andcyan-400in dark.chart-8isgray-500in light andgray-400in dark.
The reason is contrast against the card. WCAG 1.4.11 asks for 3:1 between a graphical object and what sits next to it. Each series token and the axis colour are part of the 96 contrast pairs the tokens package checks against bg/surface in both modes. In light mode the lowest is chart-3, amber, at 3.19:1. In dark mode every series token is above 6:1. The same amber at step 600 on a dark surface would look heavy and dull, so the dark mode moves it up the scale.
chart-grid is the exception. It is gray-100 in light and gray-800 in dark, around 1.1:1 to 1.3:1 against the card, and it is not in the contrast pairs. Grid lines are decorative: they help the eye, and nothing depends on seeing them. The axis labels, which carry information, use chart-axis.
chart-1 is a brand token. If you generate a theme with createTheme({ brand }), the first series and the whole heatmap ramp follow your brand.
The heatmap ramp flips
A heatmap maps values to a five-step ramp, heatmap-1 to heatmap-5. Readers expect the strongest values to stand out most against the background. On a white card that means darker. On a dark card it means lighter.
So the ramp runs in opposite directions in the two modes. In light mode, step 1 is brand-100 and step 5 is brand-900. In dark mode, step 1 is brand-900 and step 5 is brand-200. In both, step 5 has the most contrast with the card: 9.7:1 in light and 13.7:1 in dark. The highest values stand out in both modes.
The numbers on the cells switch with the ramp. Steps 1 to 3 use text/primary, and steps 4 and 5 use text/inverse. The HeatmapChart source documents the result: the cell numbers reach 4.5:1 on every step except step 3 in the dark theme, at 4.4:1.
The lightest steps are faint by design
The low end of a heatmap ramp has to be close to the background, or the ramp has no room to grow. Steps 1 and 2 are below 3:1 against the card in both modes, and step 3 is too in light mode. That is acceptable only because the value never depends on the colour:
- The legend lists the numeric range of every step, with "Less" and "More" at the ends.
valueLabelsputs numbers on the cells. The default,high, labels the two darkest steps;alllabels every cell that has room.- Each cell is a focusable point whose accessible name and tooltip give its row, column and value.
- A data table with every value backs the chart.
A cell without data is drawn as a dashed outline, so "no data" never looks like "zero".
Never colour alone
The same rule applies to every chart, in both modes. seriesStyle() gives each series a colour, a dash pattern and a marker shape: chart-1 is a solid line with circles, chart-2 is dashed with squares, chart-3 is dotted with diamonds, and so on. Stacked and multi-series fills add patterns. The legend uses the same marks. A line chart printed in greyscale, or read by someone who cannot separate amber from green, still shows which line is which.
import { LineChart } from "@nexera-ui/react";
<LineChart
title="Headcount by team"
description="Last 3 months"
summary="Engineering grew from 41 to 46 while Operations stayed near 32."
categories={["Nov", "Dec", "Jan"]}
series={[
{ name: "Engineering", data: [41, 44, 46] },
{ name: "Operations", data: [32, 33, 31] },
]}
/>;title and summary are required. The summary is the sentence a reader should take away, and the chart is described by it. Set styleIndex on a series to keep its colour, dash and marker when you filter the list.
How it is tested
The token audit runs the chart pairs in both modes on every test run. The chart components run axe in light and dark, and the browser run opens every chart story in Light, Dark and RTL. Draw-in animations sit behind motion-safe:, so the tests and anyone who prefers reduced motion see the final chart at once.
Two things stay with you. Pick a summary that states the main message, and choose heatmap thresholds that mean something, such as 10, 20, 30 and 40, so the legend ranges are worth reading. See the heatmap chart and line chart pages for every prop.