Theme studio

Design a theme, preview it live, then export it. Saved in this browser.

Quick picks #10b981
Generated scale
50100200300400500600700800900
Some text is below AA
Design systems

Dark mode charts that stay readable

Why the Nexera chart colours move to lighter steps in dark mode, why the heatmap ramp flips direction, and why no chart tells a value by colour alone.

NDesign systems team· 5 min read

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-1 is brand-600 in light and brand-400 in dark.
  • chart-2 is blue-600 in light and blue-400 in dark.
  • chart-3 is amber-600 in light and amber-400 in dark.
  • chart-5 is cyan-700 in light and cyan-400 in dark.
  • chart-8 is gray-500 in light and gray-400 in 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.
  • valueLabels puts numbers on the cells. The default, high, labels the two darkest steps; all labels 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.

app.tsx
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.

Keep reading

All posts →

One email when we publish.

New posts and releases, about twice a month. Or follow the RSS feed.