From one brand colour to a full light and dark theme
Most projects start with one colour. Somebody picks a brand blue, drops it into a button, and three weeks later the codebase
has eleven slightly different blues, a hover state nobody can reproduce, and a dark mode that was bolted on by inverting things
until they stopped hurting. The fix is not more discipline. It is generating the whole set from the one colour you actually chose.
A scale is lightness, not eleven decisions
A usable shade scale — 50 through 950 — is the same hue at controlled lightness steps. Convert the base colour to HSL, keep the hue,
keep most of the saturation, and walk the lightness down a curve that is tighter in the middle than at the ends:
const { h, s } = rgbToHsl(base)
const LIGHTNESS = { 50: 97, 100: 94, 200: 86, 300: 77, 400: 66, 500: 55,
600: 46, 700: 38, 800: 30, 900: 23, 950: 15 }
const shade = (step) => hslToRgb({ h, s, l: LIGHTNESS[step] })
Two small corrections make the result look designed rather than computed. Pull saturation back slightly at the 50-100 end, or your
lightest surface reads as a tinted wash instead of near-white. Do the same at 900-950, or the darkest shade turns muddy. Everything
in between keeps full saturation, because that is the range buttons and links live in.
Semantic tokens are the part that survives a redesign
Shades are raw material. What components should reference is meaning: background, foreground, primary,primary-foreground, secondary, muted, muted-foreground, accent, destructive, border, input, ring.
When bg-primary is spelled out in a component, changing the brand colour is one variable. When bg-blue-600 is spelled out,
it is a find-and-replace with casualties.
The pairing rule matters as much as the list: every surface token has a matching foreground token, and they always travel together.
That is the whole reason primary-foreground exists.
Dark mode is a different theme, not an inversion
Inverting a light theme gives you glowing white text on pure black and a primary colour that has gone neon. What actually works:
- Backgrounds sit around 8-11% lightness with a trace of the brand hue, never
#000000. A hint of hue is what stops dark UI
looking like a terminal.
- Foreground text lands near 96%, not 100% — pure white on near-black vibrates.
- The primary colour gets lighter in dark mode, not darker. A 48%-lightness blue that pops on white disappears on a dark surface;
the same hue at around 62% keeps the same visual weight.
- Borders stop being lighter than the background and start being lighter than the card. Contrast direction flips.
Let contrast pick the text colour
Never hardcode white text on a primary button. Compute it:
const onPrimary = contrastRatio(primary, WHITE) >= contrastRatio(primary, BLACK)
? "#ffffff" : "#111111"
That one line is the difference between a yellow brand colour that ships with unreadable white text and one that quietly gets black
text instead. Then check the result against WCAG — 4.5:1 for body text, 3:1 for large text and UI boundaries — with the
Contrast Checker before you sign off.
Shipping the tokens
For plain CSS, define the light set on :root, repeat the dark set inside both .dark and aprefers-color-scheme: dark media query. The class handles a manual toggle; the media query handles the visitor who never touches
one.
For Tailwind v4 there is no JS config — tokens live in your CSS entry file:
:root { --primary: #4f46e5; --primary-foreground: #ffffff; }
.dark { --primary: #818cf8; --primary-foreground: #111111; }
@theme inline {
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
}
@custom-variant dark (&:where(.dark, .dark *));
@theme inline is the important detail: without inline, the generated utility points at a variable reference that resolves to
nothing when the theme switches. For Tailwind v3, the same tokens go into theme.extend.colors as "var(--primary)" strings withdarkMode: "class".
Do it in one pass
The Palette Generator now does all of the above from a single colour: pick a base, choose a
harmony, and you get the palette, the 50-950 scale, a live light and dark preview, and an export in CSS variables, Tailwind v4@theme, Tailwind v3 config, SCSS or JSON tokens. Everything is computed in your browser — no upload, no account.
Two things worth doing before you commit the output: run the primary and destructive colours through the
Colour Blindness Simulator to confirm your error state is not the same grey as your
success state for a deuteranopic visitor, and check that muted-foreground on muted still clears 4.5:1. Those two checks catch
most of what a generated theme can get wrong.