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.
| Ground | Light | Dark |
|---|---|---|
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
#ffffffcaps relative luminance atL ≤ 0.300 - ≥ 3:1 against
#171717(L = 0.00858) floors it atL ≥ 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 rung | Hex | vs #ffffff | vs #171717 | In window? |
|---|---|---|---|---|
| pink-400 | #fb64b6 | 2.76 | 6.49 | No — too light |
| pink-500 | #f6339a | 3.58 | 5.00 | Yes |
| pink-600 | #e60076 | 4.54 | 3.95 | Yes |
| pink-700 | #c6005c | 5.91 | 3.04 | Yes, at the edge |
| pink-800 | #a3004c | 7.89 | 2.27 | No — too dark |
| pink-900 | #861043 | 9.68 | 1.85 | No — 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
| # | Role | Hex | OKLCH | vs #ffffff | vs #fafafa | vs #171717 | vs #0a0a0a |
|---|---|---|---|---|---|---|---|
| 1 | brand pink (pink-600) | #e60076 | L 0.598 C 0.242 H 1.4 | 4.54 | 4.35 | 3.95 | 4.36 |
| 2 | light taupe | #a9898c | L 0.661 C 0.039 H 11.9 | 3.16 | 3.03 | 5.67 | 6.26 |
| 3 | blush | #c8749d | L 0.663 C 0.116 H 350.1 | 3.26 | 3.12 | 5.50 | 6.07 |
| 4 | dark taupe | #826466 | L 0.534 C 0.039 H 14.0 | 5.30 | 5.08 | 3.38 | 3.74 |
| 5 | crimson rose | #cc1d49 | L 0.548 C 0.204 H 14.9 | 5.47 | 5.24 | 3.28 | 3.62 |
| 6 | light stone | #8a8688 | L 0.624 C 0.006 H 345.4 | 3.59 | 3.44 | 4.99 | 5.51 |
| 7 | mauve | #a36899 | L 0.597 C 0.100 H 333.1 | 4.20 | 4.03 | 4.27 | 4.71 |
| 8 | darkest stone | #686466 | L 0.508 C 0.006 H 345.4 | 5.83 | 5.58 | 3.08 | 3.40 |
| 9 | hot pink (pink-500) | #f6339a | L 0.656 C 0.240 H 354.3 | 3.58 | 3.43 | 5.00 | 5.52 |
| 10 | mid mauve-gray | #907683 | L 0.595 C 0.037 H 346.7 | 4.11 | 3.94 | 4.36 | 4.81 |
| 11 | mulberry | #9e5c6e | L 0.555 C 0.089 H 2.8 | 4.99 | 4.78 | 3.59 | 3.97 |
| 12 | mid-dark stone | #787576 | L 0.565 C 0.004 H 354.8 | 4.56 | 4.37 | 3.93 | 4.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.
| Step | Palette variables it sets | Stylesheet property |
|---|---|---|
| 1 | cScale0, cScaleInv0, cScalePeer0, pie1, git0, gitInv0, fillType0, surface0, surfacePeer0, venn1 | --leed-chart-1 |
| 2 | cScale1, cScaleInv1, cScalePeer1, pie2, git1, gitInv1, fillType1, surface1, surfacePeer1, venn2 | --leed-chart-2 |
| 3 | cScale2, cScaleInv2, cScalePeer2, pie3, git2, gitInv2, fillType2, surface2, surfacePeer2, venn3 | --leed-chart-3 |
| 4 | cScale3, cScaleInv3, cScalePeer3, pie4, git3, gitInv3, fillType3, surface3, surfacePeer3, venn4 | --leed-chart-4 |
| 5 | cScale4, cScaleInv4, cScalePeer4, pie5, git4, gitInv4, fillType4, surface4, surfacePeer4, venn5 | --leed-chart-5 |
| 6 | cScale5, cScaleInv5, cScalePeer5, pie6, git5, gitInv5, fillType5, venn6 | --leed-chart-6 |
| 7 | cScale6, cScaleInv6, cScalePeer6, pie7, git6, gitInv6, fillType6, venn7 | --leed-chart-7 |
| 8 | cScale7, cScaleInv7, cScalePeer7, pie8, git7, gitInv7, fillType7, venn8 | --leed-chart-8 |
| 9 | cScale8, cScaleInv8, cScalePeer8, pie9 | --leed-chart-9 |
| 10 | cScale9, cScaleInv9, cScalePeer9, pie10 | --leed-chart-10 |
| 11 | cScale10, cScaleInv10, cScalePeer10, pie11 | --leed-chart-11 |
| 12 | cScale11, 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 type | Variables | Default if unset | Symptom |
|---|---|---|---|
| venn | venn1–venn8 | Derived by rotating the page ground | Every set collapses to a gray |
| xy chart | xyChart.plotColorPalette | A stock peach set | Plots in colors from no palette you own |
| Cynefin | five domain grounds on the nested cynefin object | Stock pastels, painted as attributes | Four pastel quadrants on any ground |
| event model | nine em* fill and stroke keys | Pastels on white, painted as attributes | Pastel-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.
| Variable | Mermaid default | Set it to | What 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.