Skip to content
KONIGI Patterns

Dashboards / Color / Dark-first data palette

‹ 2 of 6 ›

Dark-first data palette

People need chart colors that remain readable on dark backgrounds.

Updated September 10, 2026

Problem

Chart colors chosen on a light background can become hard to distinguish on a dark screen.

Solution

Design and test the palette against the backgrounds people will use. If the main setting is dark, start there and tune the light version separately.

Check text, thin chart lines, and muted labels on ordinary displays as well as the design monitor. Keep warning colors distinct from category colors, and avoid relying on color alone.

Support both themes when the audience needs them. Check exported charts and embedded content too, since they may appear on a different background.

Use when

Dashboards are commonly used in dark settings or the product supports both light and dark themes.

Don’t use when

The output is print, PDF or a projector, where dark backgrounds waste ink and wash out. If a dashboard is regularly screenshotted into documents, the light theme is the one that ships.

Trade-offs

Two themes require separate checks and maintenance. Very bright marks can dominate a dark chart, while muted ones can disappear. Images and exports may not fit the surrounding theme.

Checklist

  • Was the dark palette designed first, or inverted from a light one?
  • How many series colours stay distinguishable on the actual background at the actual size?
  • Is pure white or pure yellow used anywhere large, and does it halate?
  • Does every series colour meet the non-text contrast floor against the dark background?
  • Do the status colours stay distinct from the categorical series colours in both themes?
  • Have both palettes been checked for the common colour vision deficiencies?
  • Has this been looked at on the worst screen it will actually run on?
  • What happens when a dark chart is pasted into a light document?
  • Do grid lines, axes and labels recede properly, or do they compete?
  • Does the theme follow the system preference, and can it be overridden?

Compare

Grafana ships dark as its default theme and has done for its whole life, which is why its palette reads as the reference NOC palette and why its light theme has always felt like the derived one. Netdata also defaults dark and pushes further into density, which makes its per-series distinctions harder and its resting state calmer. Honeycomb supports both and leans on a heatmap where the colour ramp rather than the categorical palette carries the meaning, so the theme question is mostly about the ramp’s endpoints. ColorBrewer predates all of this and is still the most useful reference for the underlying problem, because it was built around whether a scheme survives colourblind viewers, photocopying and projection, which is the same durability question a dark theme asks. Cloudflare Radar ships both grounds as equals and holds its hues fixed across them, which shows what that costs. Capture the same page twice and the dark blue that dominates on white is the line closest to the background on charcoal, while the pale blue that was the quiet one carries the chart. Nothing was swapped; the lightness relationship simply ran out of room.

Categorical series palette distinguishes separate series. Semantic status color defines warnings and health. Grayscale with alerts limits competing colors.

Wallboard mode adds distance and display constraints. Sequential and diverging scales handles color ranges for values.

Dark-first data palette anatomy The same six grey chips, from light to dark, shown over a white ground and over a charcoal one. On white almost every chip separates from the background. On charcoal only the lightest three do, and the usable lightness range collapses to a third of what it was. The same six values, two grounds 88 72 56 40 24 08 On white On charcoal 1 2 1 THE FULL RANGE On white, a colour is separated from the ground mostly by how dark it is, and nearly every lightness value is available to you. 2 THE RANGE COLLAPSES On charcoal everything usable has to be lighter than the ground. Hue and saturation take over work they were not carrying before. Which is why inverting a light palette doesn't work, and why you start here.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A palette designed on white and shipped on charcoal usually fails, because the mid-tone amber that read as warning on paper vanishes. Every value here is measured against both grounds, and the rows that miss the floor say so.

Tokens
--muted-foreground--status-critical
887256402408
1.4:1 below floor2.2:1 below floor3.6:16.5:111.5:117.8:1
On white · 4 of 6 reach 3:1
887256402408
12.5:17.8:14.7:12.6:1 below floor1.5:1 below floor1.0:1 below floor
On charcoal · 3 of 6 reach 3:1

ContrastLadder.tsxEvery palette value on one ground, with the WCAG ratio each reaches and the 3:1 non-text floor applied per row.

import { contrast } from "./contrast";

/** WCAG 2.2 SC 1.4.11. A chart element carries meaning, so it is non-text
 *  content and 3:1 is the floor, on every ground the chart will be shown on. */
const FLOOR = 3;

export type Step = { label: string; color: string };

/**
 * One ground, every value of the palette on it, and the ratio each one
 * actually reaches. The ground is a prop rather than the page background
 * because the point is to run this against the charcoal the chart ships on,
 * not the white it was designed on.
 */
export function ContrastLadder({ label, ground, steps, floor = FLOOR }: {
  label: string;
  /** The background the chart will be drawn on, as #rrggbb. */
  ground: string;
  steps: Step[];
  floor?: number;
}) {
  const rows = steps.map((s) => ({ ...s, ratio: contrast(s.color, ground) }));
  const passing = rows.filter((r) => r.ratio >= floor).length;

  return (
    <figure className="m-0">
      <div className="grid grid-cols-[auto_220px_auto] items-stretch gap-x-2">
        {/* The labels sit outside the ground, so they stay readable on charcoal. */}
        <div className="flex flex-col justify-around py-3 text-right text-[11px] tabular-nums text-muted-foreground">
          {rows.map((r) => <span key={r.label}>{r.label}</span>)}
        </div>
        <div className="flex flex-col gap-1.5 rounded-md p-3" style={{ background: ground }}>
          {rows.map((r) => <span key={r.label} className="h-7" style={{ background: r.color }} />)}
        </div>
        <div className="flex flex-col justify-around py-3 text-[11px] tabular-nums">
          {rows.map((r) => (
            <span key={r.label} className={r.ratio >= floor ? "text-muted-foreground" : "text-status-critical"}>
              {r.ratio.toFixed(1)}:1{r.ratio < floor && " below floor"}
            </span>
          ))}
        </div>
      </div>
      <figcaption className="mt-2 text-center text-[11px] text-muted-foreground">
        {label} · {passing} of {rows.length} reach {floor}:1
      </figcaption>
    </figure>
  );
}

contrast.tsRelative luminance, the contrast ratio, and a grey at a given L* so a ladder can be stated in lightness.

/**
 * WCAG 2.2 contrast, the way SC 1.4.11 measures a non-text element against
 * its ground. Small on purpose: the check has to be cheap enough to run on
 * every colour against every ground, not once on the one someone remembered.
 */

const channel = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);

/** Relative luminance of a #rrggbb colour, 0 for black and 1 for white. */
export function luminance(hex: string) {
  const n = parseInt(hex.slice(1), 16);
  const [r, g, b] = [n >> 16, (n >> 8) & 255, n & 255].map((v) => channel(v / 255));
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

/** Contrast ratio between two colours, 1 to 21. */
export function contrast(a: string, b: string) {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
}

/** A neutral grey at a given CIE L*, so a ladder can be stated in lightness. */
export function greyAtLightness(L: number) {
  const Y = L > 8 ? ((L + 16) / 116) ** 3 : L / 903.3;
  const c = Y <= 0.0031308 ? 12.92 * Y : 1.055 * Y ** (1 / 2.4) - 0.055;
  const h = Math.round(c * 255).toString(16).padStart(2, "0");
  return `#${h}${h}${h}`;
}

demo.tsxHow it is called: six greys by L*, once against white and once against the charcoal the chart ships on.

import { ContrastLadder, type Step } from "./ContrastLadder";
import { greyAtLightness } from "./contrast";

/**
 * Six greys stated in CIE L*, run against the two grounds the chart will
 * live on. On white the dark end passes; on charcoal only the light end
 * does, and the usable range is the rows that clear the floor.
 */
const STEPS: Step[] = [88, 72, 56, 40, 24, 8].map((L) => ({
  label: String(L).padStart(2, "0"),
  color: greyAtLightness(L),
}));

export default function Demo() {
  return (
    <div className="flex flex-wrap gap-6">
      <ContrastLadder label="On white" ground="#ffffff" steps={STEPS} />
      <ContrastLadder label="On charcoal" ground="#1c1c1e" steps={STEPS} />
    </div>
  );
}
What it renders. Identical markup in all three panes, with only the token values changing.

Examples

Screenshots are grouped by product and include the capture date. Dashed boxes highlight this pattern. Notes appear below each screenshot and when you hover over a box.

Cloudflare Radar

Public internet-traffic dashboards with location and time controls and links from summaries to detailed views.

Worldwide Overview, dark September 11, 2026 Public site, signed out; no version string exposed medium · dark · desktop-web
This capture shows Radar’s dark theme alongside the light version captured minutes earlier. The traffic series use the same hues, but the darker blue is harder to distinguish from the dark background. The lighter segments in the stacked bars also stand out more. The red browser-change labels remain prominent in both themes. The pair shows why a palette needs testing on each background.
Show 2 more examples Hide the rest

Grafana

Examples of panel grids, shared selectors, stat tiles, and chart configuration.

Examples / Stats, light theme September 10, 2026 Grafana Play (signed out; ?theme=light) dense · light · desktop-web
This light-theme capture shows the same stat-panel options as the dark version. Several value colors become pale against white, especially pink and orange. Whole-tile fills retain a different relationship between text and background, while the faint sparkline areas are harder to see. The pair is useful for checking each treatment on both backgrounds rather than assuming a theme switch preserves readability.
Examples / Stats September 10, 2026 Grafana Play (signed out; no version string exposed) dense · dark · desktop-web
Grafana’s stat showcase compares value colors, background fills, layouts, and text options. Most examples emphasize presentation without adding a comparison value or clear reference point. The No text panel uses color alone, leaving cells without labels or numbers. These are useful demonstrations of settings, but a working dashboard needs enough context to explain each value and state.
  • KPI tile A label, value, unit, and sparkline appear without an explicit comparison.
  • Dark-first data palette The dark theme makes these value colors more prominent than in the light capture.
  • Sparkline Sparklines use individual scales, so row-to-row comparison needs care.
  • Semantic status color Whole-tile color strongly emphasizes every value in this group.
  • Grayscale with alerts Multiple strong colors compete for attention.
  • KPI tile Values appear without labels explaining what they measure.
  • Semantic status color The cells have no text, leaving color as the only visible cue.
  • Header KPI strip Consistent label and value positions make the tile row easier to scan.