Diagram Color Scales

Some diagrams need a color per series rather than a color per role, and no brand palette supplies one. A pie with twelve slices, a git graph with eight branches, a journey with six actors: each needs colors that are distinguishable from each other and legible on both of your page grounds. Those are two different constraints and they pull in opposite directions.

This page is the method for deriving that set for your own brand. Leed’s twelve are the worked example, not the answer — and because the arithmetic has nothing to do with Mermaid, the same window, the same two axes and the same two published numbers work for any chart library.

Why a categorical ramp is a separate problem

Role colors — border, ground, text, accent — are chosen against one thing at a time. A border is judged against the surface behind it; a text color against the ground it sits on. You can pick each one in isolation and be right.

Categorical colors are chosen against each other and against both grounds simultaneously, and there are twelve of them. That is a different exercise, and reaching for your brand ramp is the wrong first move.

What draws from the set, so the stakes are concrete: pie slices, git branches, journey and timeline actor dots, gantt sections, mindmap, treemap and radar series, kanban column tints, quadrant fills and class-diagram fill types. One badly chosen step shows up in nine places.

Say what your grounds are before you pick anything

This is the first step and the one people skip.

Write down the four surfaces a chart color actually lands on: the page canvas in light and in dark, and the recessed or card surface in each. Resolve them to hex — not to token names — because the arithmetic below needs numbers.

GroundLightDark
Page canvas (--bg)#ffffff#171717
Recessed surface (--bg-secondary)#fafafa#1d1d1d
Card / node fill (--card)#ffffff#0a0a0a

Those are leed.ai’s, resolved from tailwind/site/theme.css. To find yours, read the same tokens out of your own palette file — and if you have set none, you inherit Leed’s defaults, which the builder declares on :root in both schemes.

The dark ground matters more than people expect. A darker canvas is more forgiving, because contrast is measured against it. The same nominal relaxation costs more on a neutral-900 page than on a near-black one, so two sites with “a dark mode” do not get the same freedom.

The legibility window, and why it is narrower than it looks

One set of literals has to work on both grounds, because the ramp cannot be a var() — the reason is below. So the arithmetic is done once, against every ground, and the answer is a band of relative luminance.

For a target of 3:1 — WCAG 1.4.11, non-text contrast, which is the right target for a chart mark rather than for body text:

  • ≥ 3:1 against #ffffff caps relative luminance at L ≤ 0.300
  • ≥ 3:1 against #171717 (L = 0.00858) floors it at L ≥ 0.126

So every step of a both-grounds-legible ramp must sit in [0.126, 0.300] — a window only 2.4:1 wide. Compute your own from your own two extremes; the two inequalities are the whole method.

Your brand ramp will not supply twelve usable steps

This is the moment a reader would otherwise stop, so it is worth six rows. Of Tailwind’s pink 400–900, exactly three rungs fall inside Leed’s window:

Brand rungHexvs #ffffffvs #171717In window?
pink-400#fb64b62.766.49No — too light
pink-500#f6339a3.585.00Yes
pink-600#e600764.543.95Yes
pink-700#c6005c5.913.04Yes, at the edge
pink-800#a3004c7.892.27No — too dark
pink-900#8610439.681.85No — too dark

Run that check on your own ramp before you do anything else. Expect to derive most steps off-ramp, and to anchor one or two on real brand rungs so the set still reads as yours rather than as twelve colors a script produced.

Twelve steps cannot be separated on lightness alone in a window that narrow

So build on two axes, lightness and chroma. The pattern that works:

  • Alternate a hue step with a near-neutral step, so neighboring series never collide.
  • Keep the hue steps inside a narrow arc of your brand hue — Leed’s stay between 333° and 15° in OKLCH, mauve through pink to rose.
  • Tint the neutrals toward that hue so the set reads as one family rather than as brand-plus-gray.

Chroma tiers, as a starting point in OKLCH: saturated ≈ 0.20–0.24, mid ≈ 0.09–0.12, near-neutral ≈ 0.004–0.04. The tiers, not the hues, are what keep twelve steps apart.

Publish two numbers, and ship the script

This is what makes a color scale reviewable instead of arguable.

For every step: contrast against all of your grounds. For the set: minimum pairwise perceptual separation, measured as Euclidean distance in OKLab (written ΔE_ok) rather than by eye — because adjacent-in-the-list is not the only pair a reader sees. A pie puts every slice next to two others, and a legend puts all twelve in a column.

The measurement script

About fifty lines of arithmetic with no dependencies: sRGB ↔ OKLCH conversion, WCAG relative luminance, and the contrast ratio. Re-run it over the whole set after any brand change rather than trusting a table.

import math

def hex_to_rgb(h):
    h = h.lstrip('#')
    return [int(h[i:i+2], 16) / 255 for i in (0, 2, 4)]

def lin(c):
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4

def lum(h):
    r, g, b = hex_to_rgb(h)
    return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)

def ratio(h1, h2):
    a, b = lum(h1), lum(h2)
    if a < b:
        a, b = b, a
    return (a + 0.05) / (b + 0.05)

def srgb_to_oklab(h):
    r, g, b = [lin(x) for x in hex_to_rgb(h)]
    l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
    m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
    s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
    l_, m_, s_ = l ** (1 / 3), m ** (1 / 3), s ** (1 / 3)
    return (
        0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
        1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
        0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
    )

def delta_e_ok(h1, h2):
    a, b = srgb_to_oklab(h1), srgb_to_oklab(h2)
    return math.dist(a, b)

GROUNDS = ["#ffffff", "#fafafa", "#171717", "#0a0a0a"]
RAMP = ["#e60076", "#a9898c", "#c8749d", "#826466", "#cc1d49", "#8a8688",
        "#a36899", "#686466", "#f6339a", "#907683", "#9e5c6e", "#787576"]

for i, c in enumerate(RAMP, 1):
    print(i, c, [round(ratio(c, g), 2) for g in GROUNDS])

print("min pairwise", round(min(
    delta_e_ok(a, b) for i, a in enumerate(RAMP) for b in RAMP[i + 1:]
), 3))

Swap GROUNDS and RAMP for your own and read the two floors off the output.

Leed’s numbers, as the worked example

#RoleHexOKLCHvs #ffffffvs #fafafavs #171717vs #0a0a0a
1brand pink (pink-600)#e60076L 0.598 C 0.242 H 1.44.544.353.954.36
2light taupe#a9898cL 0.661 C 0.039 H 11.93.163.035.676.26
3blush#c8749dL 0.663 C 0.116 H 350.13.263.125.506.07
4dark taupe#826466L 0.534 C 0.039 H 14.05.305.083.383.74
5crimson rose#cc1d49L 0.548 C 0.204 H 14.95.475.243.283.62
6light stone#8a8688L 0.624 C 0.006 H 345.43.593.444.995.51
7mauve#a36899L 0.597 C 0.100 H 333.14.204.034.274.71
8darkest stone#686466L 0.508 C 0.006 H 345.45.835.583.083.40
9hot pink (pink-500)#f6339aL 0.656 C 0.240 H 354.33.583.435.005.52
10mid mauve-gray#907683L 0.595 C 0.037 H 346.74.113.944.364.81
11mulberry#9e5c6eL 0.555 C 0.089 H 2.84.994.783.593.97
12mid-dark stone#787576L 0.565 C 0.004 H 354.84.564.373.934.34

Worst case 3.03, across all four grounds. Minimum pairwise separation 0.043 ΔE_ok. Every step clears 3:1 in both schemes from one set of literals.

The honest trade, in one sentence: this set gives up adjacent separation compared with a ramp built on lightness swings, because those swings run outside the window. The minimum adjacent distance is 0.067, against 0.173 for an earlier Leed ramp that failed four steps in dark mode.

If you relax the floor, say so out loud

Dropping the target from 3:1 to 2.5:1 widens the window from [0.126, 0.300] to [0.097, 0.370] — roughly 60% more luminance range. That buys easier at-a-glance reading in a twelve-slice pie, at the cost of steps that start to disappear against one ground.

It is a legitimate trade and it is not a silent one. Price it with the two inequalities above rather than taking a rule from this page, and note that the cost is asymmetric: a site whose dark canvas is #171717 loses more for the same relaxation than one whose canvas is nearer black.

Where the twelve values go

They are spelled twice, on purpose, and nothing enforces that the two copies agree.

In the palette, as literal hex

This is the authoritative copy. One value fans out across eight variable families, and the families stop at different counts because Mermaid’s key counts differ — that is not an omission.

StepPalette variables it setsStylesheet property
1cScale0, cScaleInv0, cScalePeer0, pie1, git0, gitInv0, fillType0, surface0, surfacePeer0, venn1--leed-chart-1
2cScale1, cScaleInv1, cScalePeer1, pie2, git1, gitInv1, fillType1, surface1, surfacePeer1, venn2--leed-chart-2
3cScale2, cScaleInv2, cScalePeer2, pie3, git2, gitInv2, fillType2, surface2, surfacePeer2, venn3--leed-chart-3
4cScale3, cScaleInv3, cScalePeer3, pie4, git3, gitInv3, fillType3, surface3, surfacePeer3, venn4--leed-chart-4
5cScale4, cScaleInv4, cScalePeer4, pie5, git4, gitInv4, fillType4, surface4, surfacePeer4, venn5--leed-chart-5
6cScale5, cScaleInv5, cScalePeer5, pie6, git5, gitInv5, fillType5, venn6--leed-chart-6
7cScale6, cScaleInv6, cScalePeer6, pie7, git6, gitInv6, fillType6, venn7--leed-chart-7
8cScale7, cScaleInv7, cScalePeer7, pie8, git7, gitInv7, fillType7, venn8--leed-chart-8
9cScale8, cScaleInv8, cScalePeer8, pie9--leed-chart-9
10cScale9, cScaleInv9, cScalePeer9, pie10--leed-chart-10
11cScale10, cScaleInv10, cScalePeer10, pie11--leed-chart-11
12cScale11, cScaleInv11, cScalePeer11, pie12--leed-chart-12

Two things about that fan-out are decisions rather than mechanics.

Setting Inv and Peer equal to the base value is a deliberate refusal of Mermaid’s invert and lighten maths, which otherwise invents colors nobody chose. It is a real trade — an “inverse” equal to its base would make inverse-colored text invisible on its own fill — so it is worth re-checking after an upgrade, but nothing currently paints wrong across a full render of every type.

Mermaid also declares a thirteenth, cScale12. The convention is to wrap it back to step 1, which is what Mermaid itself does in its own themes. pie0 is read by nothing; leave it alone rather than “fixing” it.

Not every one of these has to be a literal. The rule is the one on theming diagrams: a value must be literal where it is handed to a renderer that indexes an array, emitted into an SVG attribute, or passed through Mermaid’s color maths. cScale* is all three, so it is frozen. pie1–pie12 and the journey actor fills are emitted straight through, so leed.ai writes those as var(--chart-N) pointing back at the one authoritative block in tailwind/site/theme.css — which takes the number of duplicated spellings from five to two.

{
  "cScale0": "#e60076",
  "cScale1": "#a9898c",
  "pie1": "var(--chart-1)",
  "pie2": "var(--chart-2)"
}

Do not pre-compensate for Mermaid’s derivation

The trap that produces a ramp nobody can read back. Mermaid seeds your overrides, runs its own darken and lighten passes over them, and then assigns your overrides a second time. An explicitly set ramp value is therefore the value that renders.

So darkening your hexes to fight a derivation that gets undone puts the whole set off by one pass. Set the color you want.

In the stylesheet, as custom properties

The mirror, scoped to pre.mermaid rather than :root — the zoom stage clones a pre.mermaid carrier along with the diagram, so this scope reaches both the page copy and the zoomed copy, and it does not leak twelve chart names into the global namespace.

pre.mermaid {
  --leed-chart-1: var(--chart-1);   /* brand pink — pink-600 */
  --leed-chart-2: var(--chart-2);   /* light taupe */
  /* … through 12 */
}

Be honest about its status, because that honesty is the lesson. At Mermaid 11.17.2 the class-indexed types that would need this mirror — radar curves, treemap sections and leaves, venn sets — all read the palette instead. Nothing consumes it today. It is kept as the CSS-side lever if a future release stops reading the palette for one of them, and as the published contract a customer overrides to restyle series without touching the JSON. If it is still unreferenced at the next upgrade review, deleting it is defensible.

A customer who overrides --leed-chart-7 and sees nothing change needs to have been told that.

Four families whose colors the palette does not follow the page for

These types need explicit literals of their own, because Mermaid never exposes a page-following value for them. Each default is given so you recognize the symptom before you go looking for the cause.

Diagram typeVariablesDefault if unsetSymptom
vennvenn1–venn8Derived by rotating the page groundEvery set collapses to a gray
xy chartxyChart.plotColorPaletteA stock peach setPlots in colors from no palette you own
Cynefinfive domain grounds on the nested cynefin objectStock pastels, painted as attributesFour pastel quadrants on any ground
event modelnine em* fill and stroke keysPastels on white, painted as attributesPastel-on-white in both schemes

plotColorPalette is the odd one: a single comma-separated hex string rather than one key per series.

{
  "xyChart": {
    "plotColorPalette": "#e60076,#a9898c,#c8749d,#826466,#cc1d49,#8a8688,#a36899,#686466,#f6339a,#907683"
  }
}

Pie needs four more variables or the set you computed is not what renders

Its own section, because the failure is arithmetic rather than visual and nobody finds it by looking.

VariableMermaid defaultSet it toWhat goes wrong if you do not
pieOpacity"0.7""1"Every slice is composited with the ground; the published ratios are wrong
pieStrokeColor"black"var(--bg)A 2px black outline on every slice — defensible on white, wrong on any dark page
pieOuterStrokeColor"black"var(--border)A black ring around the whole chart
pieStrokeWidth"2px""1px"The outline competes with the slices

Stroking each slice with your page ground is the trick worth keeping: a hairline of the canvas separates two adjacent slices without adding a thirteenth color to the set.

Add the three pie text colors as well — pieTitleTextColor, pieSectionTextColor and pieLegendTextColor — which otherwise derive from a near-black default and are unreadable in dark mode.

{
  "pieOpacity": "1",
  "pieStrokeColor": "var(--bg)",
  "pieOuterStrokeColor": "var(--border)",
  "pieStrokeWidth": "1px",
  "pieTitleTextColor": "var(--fg)",
  "pieSectionTextColor": "var(--fg)",
  "pieLegendTextColor": "var(--fg)"
}

Here is the whole ramp exercised against the page ground and against its own neighbors at once. Switch your system theme and it redraws:

pie title Twelve steps, every one of them
  "One" : 12
  "Two" : 11
  "Three" : 10
  "Four" : 9
  "Five" : 9
  "Six" : 8
  "Seven" : 8
  "Eight" : 7
  "Nine" : 7
  "Ten" : 6
  "Eleven" : 6
  "Twelve" : 5

And the git / gitInv half of the fan-out, which draws from the same twelve through a different family:

gitGraph
  commit
  branch palette
  commit
  branch stylesheet
  commit
  checkout palette
  merge stylesheet
  checkout main
  merge palette
  commit

The journey actor dots draw from the same set again, through actor0–actor5:

journey
  title Deriving a ramp
  section Measure
    Resolve the grounds: 5: Designer
    Compute the window: 4: Designer, Developer
  section Build
    Pick twelve steps: 3: Designer
    Verify pairwise: 5: Developer

Ordinal or categorical — color them differently, and know why

The one genuinely editorial section on this page, and the one worth carrying to charts that have nothing to do with Mermaid.

A quadrant chart’s axes are ordinal. A monotone fade from strong to faint says something true about it: more is more.

Cynefin’s four domains are categories. Clear is not “more” than Chaotic. A fade there makes the two faintest domains nearly identical and, worse, invites the reader to see an order that is not in the model. Alternate hues and spread the luminance for the categorical case.

Same library, same file, opposite advice — and the difference is in the data rather than in the diagram.

Status colors are not part of the ramp

The boundary, stated once so nobody spends a ramp step on a meaning.

A Mermaid palette wants error and success, and it has no slot for warning. The consumers are errorBkgColor / errorTextColor, gantt’s critBkgColor / critBorderColor, and gantt’s doneTaskBkgColor / doneTaskBorderColor. Two further literals belong to the error token rather than the ramp, because both mean danger boundary: cynefin.cliffColor and wardleyEvolutionColor.

Where those semantic tokens come from, and why three registers rather than five, is theming alerts. This page only says which Mermaid variables consume them, and that a categorical step must never be spent on a semantic meaning — a reader who has learned that step 5 means “crimson rose, the fifth series” cannot also read it as “critical”.

Changing the scale later

Three lines.

The JSON takes no comments, so the derivation table lives in the companion stylesheet. Every hex in the palette is a hand-frozen resolution of a decision nothing re-checks. And the way to change one step is to re-run the script over the whole set, because a step that clears both grounds on its own can still land on top of its neighbor.

The file these twelve values go into, and the rules about which of them may be a var(), are on theming diagrams. The grounds this page measures against are your own site tokens, cataloged on the site token contract. And there is one set of literals rather than two because a diagram redraws itself when the reader’s system setting changes — the mechanism is on dark mode.

ESC