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.
Tinted neutrals carry a trace of the brand hue, which is what makes a grey look like it belongs to the blue.
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.
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.
Send invoice Save as draft
Paid on 3 April
Due in two days
Card declined
Invoices
Invoice 1042
Muted text, which is where a palette usually fails: it is the smallest type on the least contrast.
Send invoice Save as draft
Paid on 3 April
Due in two days
Card declined
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
: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
- 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. - 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.
- 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.
- 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.
- Copy the CSS, or switch the output to a
theme.jsonpalette or a Tailwind 4@themeblock.
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?
: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?
Can I add my own token to the set?
--ui-bg in the contrast
checker first.