UI Color Palette Generator

One brand colour becomes a full interface palette: surfaces, borders, text, primary and status colours, each fitted to the contrast it needs.

Enable JavaScript to change the colour; the palette below is for the default.

Hex or rgb(). A grey has no hue to build on, so it is refused.

Neutrals

Tinted neutrals carry a trace of the brand hue, which is what makes a grey look like it belongs to the blue.

Modes
Contrast target

AA is 4.5:1 for text, AAA is 7:1. Borders and buttons are held to 3:1 either way, which is what WCAG asks of them.

Gives –ui-bg, –ui-text and the rest. Anything that is not a CSS identifier is dropped.

Output

theme.json and Tailwind take one mode, so they are written from the first one selected.

Invoices

Invoice 1042

Muted text, which is where a palette usually fails: it is the smallest type on the least contrast.

[email protected]

Send invoice Save as draft

Paid on 3 April

Due in two days

Card declined

Light

Invoices

Invoice 1042

Muted text, which is where a palette usually fails: it is the smallest type on the least contrast.

[email protected]

Send invoice Save as draft

Paid on 3 April

Due in two days

Card declined

Dark

18 of 18 checked colours clear what they need.

Light

  • --ui-bg #F1F7FF 1.00
  • --ui-surface #FFFFFF 1.08
  • --ui-surface-sunken #E5EDFD 1.09
  • --ui-border #D4DEF4 1.25
  • --ui-border-strong #838FA9 3.01 3.0
  • --ui-text #252E40 12.63 4.5
  • --ui-text-muted #656F82 4.70 4.5
  • --ui-primary #1E56EC 5.42 3.0
  • --ui-primary-hover #0A3DD3 7.44
  • --ui-primary-contrast #FFFFFF 5.84 4.5
  • --ui-primary-soft #E3F1FF 1.07
  • --ui-link #1E56EC 5.42 4.5
  • --ui-success #0A8234 4.58 4.5
  • --ui-success-soft #DFF8E1 1.04
  • --ui-warning #A06200 4.59 4.5
  • --ui-warning-soft #FFEED3 1.06
  • --ui-danger #AF3C3A 5.52 4.5
  • --ui-danger-soft #FFE7E3 1.10

Dark

  • --ui-bg #0C0F16 1.00
  • --ui-surface #111827 1.08
  • --ui-surface-sunken #040710 1.05
  • --ui-border #262E3E 1.41
  • --ui-border-strong #556178 3.07 3.0
  • --ui-text #D9E5FD 15.13 4.5
  • --ui-text-muted #8E99AC 6.67 4.5
  • --ui-primary #1E56EC 3.28 3.0
  • --ui-primary-hover #326EFF 4.39
  • --ui-primary-contrast #FFFFFF 5.84 4.5
  • --ui-primary-soft #17233C 1.23
  • --ui-link #3471FF 4.52 4.5
  • --ui-success #369E4E 5.62 4.5
  • --ui-success-soft #112B16 1.26
  • --ui-warning #D79700 7.59 4.5
  • --ui-warning-soft #312003 1.22
  • --ui-danger #D15C56 4.91 4.5
  • --ui-danger-soft #391917 1.21
ui-palette.css
:root {
	--ui-bg: #F1F7FF;
	--ui-surface: #FFFFFF;
	--ui-surface-sunken: #E5EDFD;
	--ui-border: #D4DEF4;
	--ui-border-strong: #838FA9;
	--ui-text: #252E40;
	--ui-text-muted: #656F82;
	--ui-primary: #1E56EC;
	--ui-primary-hover: #0A3DD3;
	--ui-primary-contrast: #FFFFFF;
	--ui-primary-soft: #E3F1FF;
	--ui-link: #1E56EC;
	--ui-success: #0A8234;
	--ui-success-soft: #DFF8E1;
	--ui-warning: #A06200;
	--ui-warning-soft: #FFEED3;
	--ui-danger: #AF3C3A;
	--ui-danger-soft: #FFE7E3;
}

@media (prefers-color-scheme: dark) {
	:root:not([data-theme="light"]) {
		--ui-bg: #0C0F16;
		--ui-surface: #111827;
		--ui-surface-sunken: #040710;
		--ui-border: #262E3E;
		--ui-border-strong: #556178;
		--ui-text: #D9E5FD;
		--ui-text-muted: #8E99AC;
		--ui-primary: #1E56EC;
		--ui-primary-hover: #326EFF;
		--ui-primary-contrast: #FFFFFF;
		--ui-primary-soft: #17233C;
		--ui-link: #3471FF;
		--ui-success: #369E4E;
		--ui-success-soft: #112B16;
		--ui-warning: #D79700;
		--ui-warning-soft: #312003;
		--ui-danger: #D15C56;
		--ui-danger-soft: #391917;
	}
}

:root[data-theme="dark"] {
	--ui-bg: #0C0F16;
	--ui-surface: #111827;
	--ui-surface-sunken: #040710;
	--ui-border: #262E3E;
	--ui-border-strong: #556178;
	--ui-text: #D9E5FD;
	--ui-text-muted: #8E99AC;
	--ui-primary: #1E56EC;
	--ui-primary-hover: #326EFF;
	--ui-primary-contrast: #FFFFFF;
	--ui-primary-soft: #17233C;
	--ui-link: #3471FF;
	--ui-success: #369E4E;
	--ui-success-soft: #112B16;
	--ui-warning: #D79700;
	--ui-warning-soft: #312003;
	--ui-danger: #D15C56;
	--ui-danger-soft: #391917;
}

Picking eleven greys and a blue is the easy part of a palette. The part that fails review is muted text on a tinted background at 4.1:1, a button label at 2.8:1, and an input border nobody can find. Those are arithmetic, not taste, so this does them: every colour here that carries text or draws an outline has its lightness walked away from the background until it clears the ratio that use needs, and the ratio it landed on is printed beside it.

Eighteen tokens, in light and dark, from one brand colour.

How to use

  1. Put in the brand colour. Hex or rgb() both work. A grey is refused, because there is no hue in it to build the neutrals from.
  2. Choose tinted or grey neutrals. Tinted carries a trace of the brand hue into every surface and border, which is what makes a grey look like it belongs to the blue rather than sitting next to it.
  3. Pick the contrast target. AA holds text to 4.5:1, AAA to 7:1. Borders and button fills are held to 3:1 either way, which is what WCAG 2.2 asks of non-text contrast.
  4. Read the mock interface. It is drawn with the tokens, so a primary that is too pale to see on the background is obvious there before it is obvious in a pull request.
  5. Copy the CSS, or switch the output to a theme.json palette or a Tailwind 4 @theme block.

Example

Start from amber, #F59E0B, in light mode. The fitting changes two things and leaves the rest alone:

:root {
	--ui-bg: #FEF5EC;
	--ui-surface: #FFFFFF;
	--ui-border-strong: #A08B75; /* 3.02:1 */
	--ui-text: #392B19;          /* 12.71:1 */
	--ui-text-muted: #7E6F5E;    /* 4.51:1 */
	--ui-primary: #CC7800;       /* 3.12:1 */
	--ui-primary-contrast: #111111;
	--ui-link: #AB5900;          /* 4.68:1 */
}

--ui-primary is not #F59E0B. Amber against a near-white background is about 1.9:1, so a filled button in it has no edge at all; the fitting darkened it to #CC7800 to reach 3:1. --ui-link went further, to #AB5900, because a link is text and text needs 4.5:1. And --ui-primary-contrast came back #111111 rather than white: on amber, black reads better, which is the opposite of what the same palette does with the blue.

In dark mode the same colour is left alone. #F59E0B against #140E08 is 8.9:1, so nothing needs moving, and the brand colour is the brand colour.

Pitfalls

A brand colour is not a primary. Most brand colours were chosen against white paper, and mid-tone ones cannot be a button fill and a link colour at the same time. This gives you both, from the same hue, at different lightnesses; using the raw brand hex for everything is what puts a 2:1 link on a page.

Tinted neutrals need a hue with some chroma in them. A brand colour that is nearly grey gives nearly grey neutrals, which is fine, but the tint will not be visible and switching to grey makes the intent clearer.

theme.json has one palette. WordPress has no second palette for dark mode, so the theme.json output is the first mode selected. If you need both, ship the CSS variables and point theme.json at them.

Status colours keep their own hues. Success stays green, danger stays red, and neither is tinted with the brand. A green that has been pulled towards a red brand stops reading as success, which is a worse outcome than a palette that is one hue short of matching.

The fitting is contrast, not beauty. A colour that has been moved far to clear AAA can end up muddy. If a fitted colour looks wrong, the answer is usually a different brand colour, or AA rather than AAA for body text with a larger size.

Compatibility

The colours are built in OKLCH and written as hex, so the output works everywhere, including browsers with no oklch() support. OKLCH is only used here for the arithmetic: nudging lightness in it is perceptually even, so the fitting loop lands on the first colour that clears the ratio instead of overshooting the way an HSL one does.

Contrast is WCAG 2 relative luminance, the same formula the contrast checker on this site uses. The two agree to the second decimal.

The theme.json output is version 3, which WordPress 6.6 and later read. The Tailwind output is the @theme block Tailwind 4 uses; for Tailwind 3, copy the CSS variables and reference them in tailwind.config.js.

Frequently asked questions

Why are there two dark mode rules in the CSS?
A media query for sites with no theme switch, and :root[data-theme="dark"] for sites that have one. The media query is written as :root:not([data-theme="light"]) so that a visitor who has explicitly chosen light on a dark-preferring system still gets light.
What does “18 of 18 checked colours clear what they need” mean?
Eighteen tokens have a contrast requirement, and all of them met it. Surfaces are not checked, because nothing is measured against them until you put text on them, and that text is one of the tokens that was checked.
Can I add my own token to the set?
Not in the tool, but the output is plain custom properties, so another line next to them works. If it carries text, check it against --ui-bg in the contrast checker first.
Why is muted text always held to 4.5:1, even on AAA?
Because it is still body text. AAA moves the main text colour to 7:1; holding the muted colour there too would make it nearly the same colour as the main one, which defeats the purpose of having it.
Does the preview show real components?
It shows a card, a button, an input, a link and the three status messages, drawn with the tokens and nothing else. It is enough to see the failures that matter and small enough that nothing in it is decoration.
Weekly drops

New tools, when there are new tools

One email when something worth using ships. No schedule to fill, so no filler.

Your address goes nowhere else, and one click unsubscribes.