Color theory, briefly
Every color you can describe has three properties, whatever notation you write it in: a hue (which color it is, as an angle around a wheel), a saturation or chroma (how far it is from grey), and a lightness (how much light it appears to reflect). Almost every practical decision in interface color is really a decision about one of those three in isolation.
The useful consequence: when something is wrong, name the axis. A palette that feels muddy is usually a chroma problem. A palette that feels chaotic is usually a hue problem. A palette that fails accessibility is always a lightness problem, because contrast responds to lightness and barely to the other two.
The traditional artist's wheel — red, yellow, blue as primaries — describes mixing pigment. Screens mix light, where the primaries are red, green and blue, and their complements land in different places. Both models survive in design software, which is why "the complement of blue" gets two different answers depending on who you ask. For interface work, use the RGB-based model: it is what the display actually does.
Harmonies and when to use them
A harmony is a rule for picking hue angles that reliably look intentional. None of them are laws — they are shortcuts past the blank page.
| Harmony | Rule | Reach for it when |
|---|---|---|
| Monochromatic | One hue, varying lightness and chroma | You want calm and cannot go wrong. The default for content-heavy products. |
| Analogous | Neighbours, roughly 30° apart | You want warmth and variety without tension — marketing pages, illustration. |
| Complementary | Opposite, 180° apart | You need one thing to stand out hard: a call to action against everything else. |
| Split complementary | The two neighbours of the opposite | You want complementary contrast without the vibration a true opposite pair causes. |
| Triadic | Three hues 120° apart | You need three equally weighted categories — charts, tags, status systems. |
| Tetradic | Two complementary pairs | Rarely. Four saturated hues is more than most interfaces can absorb. |
The mistake that ruins harmonies in practice is applying them at equal weight. A complementary palette where both colors cover the same area of the screen does not read as balanced — it reads as an argument. Pick a dominant hue, give it most of the surface, and let the second hue appear at perhaps a tenth of the area.
The palette builder generates all six of these plus pastel, earthy, neon, shades and random, and lets you lock the colors you have already committed to so the harmony is computed around them.
Color formats: HEX, RGB, HSL and OKLCH
HEX and RGB are the same information.
#4a7ebb is three pairs of base-16 digits, which is exactly
rgb(74, 126, 187). Neither is more precise; hex is just
shorter to type and harder to do arithmetic on.
HSL exists because per-channel numbers are impossible to
reason about. Hue, saturation and lightness map onto how people actually
talk about color — "the same blue but darker" is a one-number change. Its
flaw is that its lightness is a geometric construct, not a perceptual one.
hsl(60 100% 50%) is a blinding yellow and
hsl(240 100% 50%) is a near-black blue, yet both report 50%
lightness. Any ramp built on that number will be uneven, and any
accessibility judgement made from it will be wrong.
OKLCH fixes exactly that. Same three concepts — lightness, chroma, hue — but the lightness axis is calibrated to human perception, so equal numeric steps look like equal steps. It is supported in every current browser and it is what every ramp, gradient and contrast repair on this site is computed in. If you are building a design system today, store your palette in OKLCH and let the tooling emit hex.
HSB / HSV is what Figma, Sketch and Photoshop display, and
it is not HSL. Its third channel reaches the pure hue at 100% and never
reaches white. Pasting an HSB value into a CSS hsl() function
silently produces a different color — a common and hard-to-spot handoff bug.
The converter shows both side by side.
CMYK is for print, and the conversion from RGB is approximate by nature — the real mapping depends on ink, paper and press profile. Treat any RGB-to-CMYK number as a starting point for your printer, not a specification.
Choosing a palette that survives implementation
Most palette-picking goes wrong by starting with hues. Start with roles instead, because roles are what the code needs:
- Background — the largest area. Pick this first; every other decision is relative to it.
- Surface — cards, panels, menus. Close to the background but distinguishable without a border.
- Text — must clear 4.5:1 against both background and surface. This constraint usually fixes its lightness within a narrow band.
- Primary — the one action color. Needs 3:1 against the background as a shape, and its own contrasting ink for label text.
- Accent — a second, rarer color for highlights, links or a chart series. Optional, and easy to add later.
Choose hues only after those five slots are filled. Doing it in this order means the palette is checkable before you have committed to anything visual, and it prevents the most common failure: two beautiful colors that turn out to have nearly identical lightness, so neither can sit on the other.
A quick sanity test before you commit: convert the palette to greyscale in your head, or run it through the achromatopsia simulation. If two colors that carry different meanings become the same grey, they will also collide for some readers with color vision deficiency, and in any greyscale print of your page.
WCAG contrast, and what the numbers mean
The contrast ratio between two colors is computed from their
relative luminance — a weighted sum of the linearised red,
green and blue channels, weighted 0.2126 / 0.7152 / 0.0722 because the eye
is far more sensitive to green than to blue. The ratio is
(L1 + 0.05) / (L2 + 0.05), where the 0.05 stands in for
ambient reflection off the screen. It runs from 1:1 (identical) to 21:1
(black on white).
| Content | AA | AAA |
|---|---|---|
| Body text, under 24px | 4.5:1 | 7:1 |
| Large text, 24px+ or 18.66px bold | 3:1 | 4.5:1 |
| UI components, icons, focus rings | 3:1 | — |
| Disabled controls, decoration | exempt | exempt |
Three things worth knowing that the thresholds do not tell you. First, the formula is known to be imperfect: it over-rewards dark backgrounds, so light text on dark can pass at 4.5:1 and still read poorly at small sizes. Second, it says nothing about font weight — a 4.6:1 hairline weight is worse than a 4.4:1 medium. Third, it applies to what is adjacent: text inside a colored button is measured against the button, not against the page.
When a pair fails, change lightness and hold hue and chroma. That reaches the threshold with the least visible drift, and it is what the contrast checker's repair does — searching both directions and stopping at the smallest change that passes.
Building an accessible palette
Accessibility in color is two separate requirements that people routinely merge. Contrast is whether text can be read. Discriminability is whether two colors can be told apart. A palette can pass one and fail the other completely.
Contrast is handled by lightness spread. If your palette contains a real range from near-white to near-black, most pairs will pass, and the ones that do not were never going to carry text anyway.
Discriminability is handled by not relying on hue. Roughly one man in twelve has some red-green deficiency, which compresses an entire axis of the color wheel. Three fixes, in order of robustness:
- Separate by lightness. Lightness survives every form of color vision deficiency, including full achromatopsia. Two colors a couple of ramp steps apart stay distinguishable to everyone.
- Add a non-color channel. A label, an icon, a dashed line, a pattern fill. WCAG 1.4.1 requires this whenever color carries meaning — a red-and-green status dot with no other difference is a failure regardless of contrast.
- Prefer the blue-yellow axis. Blue against orange or yellow survives both deuteranopia and protanopia, which is why so many accessible chart palettes look the way they do.
The palette gallery lists the measured collision count for every palette, so you can start from one that has none.
Dark mode is not a light theme inverted
Inverting lightness produces a theme that is technically dark and looks wrong, for four reasons that each need a deliberate correction.
- Avoid pure black. #000000 next to bright text produces halation — the text appears to smear, especially for astigmatic readers. A very dark grey around #0e0e12 to #16161c is easier to read against and leaves room for surfaces to sit above the background.
- Elevation inverts. In a light theme, raised surfaces get shadows. In a dark theme, shadows are invisible — raised surfaces have to get lighter instead. Each elevation step is a lighter grey.
- Saturated colors bloom. A fully saturated hue that looked right on white will vibrate on near-black. Reduce chroma by roughly a quarter and raise lightness; this is why dark themes read as slightly pastel.
- Do not use pure white text. #ffffff on near-black is typically over 18:1, far past what is needed, and it is fatiguing. Around #e8e8ee — near 15:1 — is more comfortable and still comfortably AAA.
In practice this means a dark theme needs its own tonal ramp rather than a reversed reading of the light one. Generate the ramp, then pick the dark theme's roles from different steps — usually 950 and 900 for background and surface, 400 for the primary, and 100 for text.
From palette to design tokens
Five colors cannot express an interface. You need hover and active states, borders, dividers, disabled text, selected rows, focus rings. Improvising each of them per component is how a codebase acquires forty near-identical greys that no one dares to consolidate.
The fix is a tonal scale: each palette color expanded into numbered steps, conventionally 50 (lightest) through 950 (darkest). Every state then comes from the same family — a button is 500, its hover 600, its disabled state 200, its focus ring 400 — and the relationships hold when the hue changes.
Build the ramp in OKLCH, not HSL, or the steps will bunch up around yellows and blues. Taper chroma at both ends: a tint at full chroma looks dirty and a shade at full chroma looks muddy, so the most saturated step belongs in the middle of the ramp, around lightness 55–65%.
The builder does this expansion automatically and exports the result as CSS custom properties, SCSS variables, a Tailwind config or JSON — a five-color palette becomes 55 named tokens.
Frequently asked questions
How many colors should a palette have?
Five base colors covers the roles an interface needs — background, surface, primary, accent and text. Expanded into 50–950 tonal ramps that becomes 55 tokens, which is enough for most products. Adding a sixth and seventh base hue usually adds ambiguity rather than expressiveness.
What is the difference between a tint, a shade and a tone?
A tint is a color mixed with white, a shade is mixed with black, and a tone is mixed with grey — so a tone loses saturation while a tint or shade mostly changes lightness. A tonal scale in a design system is a mix of all three, because chroma is tapered at the ends of the ramp.
Should I store colors in HEX or OKLCH?
Author in OKLCH and ship whatever your targets need. OKLCH keeps ramps perceptually even and makes contrast repair predictable; hex remains the safest interchange format for handoff and for tools that do not parse modern CSS color syntax.
Does a dark theme need its own contrast checking?
Yes, and it usually surfaces different failures. The WCAG formula over-rewards dark backgrounds, so a dark theme can pass numerically while reading poorly at small sizes. Check the actual text sizes and weights you ship, not just the ratio.
Is color theory necessary if I use a generator?
A generator picks hue angles. It cannot tell you which color should be the background, whether your palette works in greyscale, or whether the accent is doing too much work. Those are the decisions that determine whether a palette survives implementation, and they are what this page is about.