Skip to content
Allin

Colour Schemes Are Geometry on a Wheel — and the Wheel Is Wrong

Published 8/15/2025 · 15 min read · Developer tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at Allin

Web performance · File formats

Checked against 6 sources

View profile
In short

A colour scheme is an angle. Take the hue of a base colour, add a fixed number of degrees modulo 360, and you have the scheme: complementary adds 180°, triadic adds 120° twice, analogous adds ±30°, split-complementary adds 150° and 210°, tetradic adds 90° three times. From base hue 210 that gives 30 for the complement, 330 and 90 for the triad, 180 and 240 for the neighbours, 0 and 60 for the split. That is the whole computation, and every scheme generator does it. The trouble is the circle it rotates on. HSL's hue axis is a geometric construction over the RGB cube, not a model of vision, so equal angles are not equal perceptual steps and equal HSL lightness is not equal perceived lightness. Rendered at hsl(H 100% 50%), yellow has a WCAG relative luminance of 0.9278 and blue 0.0722 — the same nominal lightness, 12.85 times the light. OKLCH and CIELAB fix this by defining lightness perceptually: the same pair is 0.968 against 0.452 in OKLCH, a ratio of 2.14. The practical consequence is unavoidable: rotation preserves nothing about contrast, so a generated palette must be checked against its backgrounds before it ships.

Complementary, triadic, analogous and split-complementary are just rotations: add 180°, 120°, 30° or 150° to a hue. The arithmetic is trivial. The problem is that the HSL hue circle is not perceptually uniform — yellow and blue at the same HSL lightness differ in luminance by 12.85 times — so a generated palette has to be contrast-checked afterwards.

The four schemes are four additions

Every named colour harmony is a set of offsets applied to one hue angle. Complementary is base + 180. Triadic is base, base + 120, base + 240. Analogous is base − 30, base, base + 30, though some tools use 20 or 45. Split-complementary is base + 150 and base + 210, which is the complement pulled apart by 30° in each direction. Tetradic, also called square, is base + 90, + 180 and + 270. Monochromatic changes no hue at all and varies only saturation and lightness.

Work an example. Take a mid blue at hue 210. Complementary gives (210 + 180) mod 360 = 30, an orange. Triadic gives 210, (210 + 120) mod 360 = 330, and (210 + 240) mod 360 = 90 — blue, pink and yellow-green. Analogous gives 180, 210, 240 — cyan, blue, violet-blue. Split-complementary gives (210 + 150) mod 360 = 0 and (210 + 210) mod 360 = 60 — red and yellow. Tetradic gives 210, 300, 30, 120. There is nothing else to the algorithm; the modulo is the only subtlety, and it exists only because the circle wraps.

Because the offsets touch only the hue, saturation and lightness are carried across unchanged. That is precisely what makes the output look like a family — and precisely what causes the failure described below, because holding S and L constant does not hold anything constant that a human eye or an accessibility checker measures.

There are two wheels, and they disagree about blue

Ask a painter what is opposite blue and the answer is orange. Ask a scheme generator and the answer is yellow. Both are right, because they are rotating on different circles. The painter's wheel has red, yellow and blue as primaries — the RYB model, systematised by Itten and taught in art schools ever since — and it describes subtractive mixing of pigment. The generator's wheel is the hue axis of HSL, a cylindrical re-parameterisation of the additive RGB cube whose primaries are red, green and blue and whose secondaries are cyan, magenta and yellow.

The numbers make the split concrete. Mapping the RYB cube onto sRGB with the standard trilinear interpolation, the RYB primary blue lands near #2a5f99 and RYB orange at #ff8000, and they sit at opposite corners of that model. On the HSL wheel, blue is hue 240 and 240 + 180 = 60, which renders as #ffff00, pure yellow. Neither wheel is lying. RYB describes what happens when you mix paint; HSL describes what happens when you mix light. They are models of different physical processes and there is no reason for their opposites to coincide.

This is worth knowing before you argue with a generator's output. If a design reference tells you the complement of your brand blue is a warm orange and the tool hands you a lemon yellow, no one has made a mistake — you are simply reading two coordinate systems. If you want the painterly answer, some generators offer an RYB mode; otherwise, rotate about 30° to 40° short of the mathematical complement and you land in the orange region.

Equal HSL lightness is not equal light

Here is the demonstration. Render nine hues at exactly hsl(H 100% 50%) — identical saturation, identical nominal lightness — and push each through the WCAG relative-luminance pipeline: divide each channel by 255, linearise it, and weight the results 0.2126 red, 0.7152 green, 0.0722 blue. Hue 60 gives #ffff00 with a relative luminance of 0.9278. Hue 240 gives #0000ff with 0.0722. Same lightness slider, 12.85 times the light.

The consequences are visible immediately in contrast terms. On a white background, that yellow scores 1.07:1 — invisible — while that blue scores 8.59:1 and clears every WCAG level. Against each other the pair scores 8.00:1, which is why yellow-on-blue is a classic signage combination and yellow-on-white is a classic mistake. The full sweep is instructive: hue 0 (#ff0000) has luminance 0.2126, hue 120 (#00ff00) has 0.7152 and hue 180 (#00ffff) has 0.7874. The spread across a single lightness ring is nearly the whole range from 0 to 1.

The reason is in the weights. Relative luminance is dominated by green, which carries 71.52% of it, while blue carries 7.22%. HSL's lightness knows nothing about that; it is defined as the midpoint between the largest and smallest RGB channel, a purely geometric quantity. At L = 50% and S = 100% every hue is by construction a colour whose channels span the full 0-to-255 range, and how much light that represents depends entirely on which channels happen to be lit.

What OKLCH and CIELAB mean by lightness

CIELAB, published by the CIE in 1976, was the first widely adopted attempt at a perceptually uniform space. Its L* axis runs 0 to 100 and is built from a cube-root compression of luminance, chosen so that equal numeric steps look like equal steps. Run the same yellow and blue through it: L* = 97.14 for #ffff00 and 32.30 for #0000ff. Still very different, but now the difference is honest — the yellow really does look far lighter — and the ratio has collapsed from 12.85 to 3.01.

OKLab, published by Björn Ottosson in 2020 and now part of CSS Color 4 as oklab() and oklch(), improves on CIELAB where CIELAB is known to be weak: hue constancy for blues and predictability under lightness changes. Its L runs 0 to 1. The yellow is 0.968, the blue 0.452. Its polar form, OKLCH, gives you lightness, chroma and hue — the same three concepts HSL claims to offer, but with the first two meaning what they say. This matters because a palette generated at fixed OKLCH lightness genuinely looks like one family of colours at one brightness, which is exactly what designers thought they were asking HSL for.

One caveat is worth stating plainly. Perceptual lightness is still not relative luminance, and neither OKLCH L nor CIELAB L* is what a WCAG checker computes. Two colours with identical OKLCH lightness can have different relative luminances, so equalising lightness perceptually does not equalise contrast ratios. It gets you a palette that looks coherent; it does not get you a palette that passes.

Equal rotations are not equal steps

The lightness problem has a twin on the hue axis. Rotating 30° in the green region — from hue 90 to hue 120 — moves you between two greens most people would struggle to name apart. Rotating 30° in the blue-to-magenta region — from 240 to 270 — takes you from pure blue to a clearly different violet. The angles are identical; the perceived distances are not, because HSL's hue is derived from the RGB cube's edges and the cube is not a sphere of perception.

Look at what this does to a triad. Rotating 120° from hue 0 gives #ff0000, #00ff00 and #0000ff, with relative luminances 0.2126, 0.7152 and 0.0722 and OKLCH lightnesses 0.628, 0.866 and 0.452. One member is nearly twice as light as another in perceptual terms and ten times as light in physical terms. Rotate the same triad from hue 90 instead and you get #80ff00, #0080ff and #ff0080, with OKLCH lightnesses 0.891, 0.615 and 0.645 — a completely different balance, from the same construction, with only the starting angle changed.

This is why a triadic scheme sometimes looks balanced and sometimes looks like one colour shouting over two others, with no change to the method. The method is blind to it. Generating the same scheme in OKLCH — rotate the hue, hold L and C — removes most of the variance, because the lightness you are holding is now the lightness you can see.

The geometry does not know about contrast

Take a real brand blue, #2563eb. In HSL it is hsl(221.2 83.2% 53.3%), and on white it scores 5.17:1 — comfortably past the 4.5:1 threshold for body text at WCAG AA. Now generate its family the standard way, rotating the hue while holding saturation and lightness. The triadic partner at +120° is #eb2563 and scores 4.22:1: fail. Split-complementary at +150° is #eb4a25, 3.79:1: fail. The complement at +180° is #ebad25, 1.99:1: fail. At +210° it is #c6eb25, 1.37:1: fail. At +240°, #63eb25, 1.56:1: fail. Five rotations, five failures, from a base that passes.

Nothing went wrong. The generator did exactly what it advertises: it rotated a hue. Contrast is a property of a pair of colours, and a scheme generator is handed one colour and an angle. It has no background to compare against and no way to know which of its outputs will end up as text and which as a decorative fill. Expecting an accessible palette out of a rotation is expecting an answer to a question that was never asked.

The workable process is two steps, not one. Generate the geometry to find hues that belong together, then adjust lightness per colour until each one clears the ratio it needs against the surface it will sit on. In practice that means keeping the hue and chroma the generator gave you and moving only lightness — which is exactly the operation that OKLCH makes predictable and HSL does not. Text roles need 4.5:1, large text and interface borders need 3:1, and decorative fills need nothing at all, so most palettes end up with two lightness variants of each hue rather than one.

WCAG relative luminance
Nine hues at identical HSL lightness (50%) and saturation (100%), measured three ways
HuesRGBWCAG relative luminanceContrast on whiteOKLCH LCIELAB L*
60 (yellow)#ffff000.92781.07:10.96897.14
180 (cyan)#00ffff0.78741.25:10.90591.11
120 (green)#00ff000.71521.37:10.86687.73
30 (orange)#ff80000.36702.52:10.73267.05
300 (magenta)#ff00ff0.28483.14:10.70260.32
0 (red)#ff00000.21264.00:10.62853.24
270 (violet)#8000ff0.11816.25:10.53140.91
240 (blue)#0000ff0.07228.59:10.45232.30
Color Scheme GeneratorDerive complementary, analogous, triadic, tetradic, split and monochromatic schemes from a base colour.Try the tool

Frequently asked questions

Why does my triadic palette look unbalanced when the maths is symmetrical?
Because the symmetry is in the angles, not in the appearance. A triad rotates 120° twice, which is perfectly even on the hue circle, but the circle it rotates on is not perceptually even. Rotating from hue 0 at hsl(H 100% 50%) gives #ff0000, #00ff00 and #0000ff with relative luminances of 0.2126, 0.7152 and 0.0722 — the green emits ten times the light of the blue at identical HSL lightness. In OKLCH the lightnesses are 0.628, 0.866 and 0.452, still a spread of nearly two to one. Whichever member happens to land in the green or yellow arc of the wheel will dominate, because those hues carry most of the luminance in sRGB. The fix is not to abandon the triad but to equalise afterwards: convert the three to OKLCH, set them all to the same L, and re-render. The hues stay where the geometry put them and the visual weight evens out. If the tool you are using cannot do that, adjusting HSL lightness by eye per colour gets you most of the way — you just cannot do it by holding one number constant.
Is the complement of blue orange or yellow?
Both, depending on which wheel you are standing on. On the artists' RYB wheel — red, yellow and blue as primaries, the model Itten systematised and art schools still teach — blue sits opposite orange, because orange is what you get by mixing the other two primaries. On the RGB wheel that HSL exposes, blue is hue 240, its opposite is hue 60, and hue 60 at full saturation is #ffff00, pure yellow. Mapping the RYB cube onto sRGB puts RYB blue near #2a5f99 and RYB orange at #ff8000, and those really are opposite corners of that model. Neither is wrong. RYB models subtractive mixing of pigment, where blue and orange paints cancel toward a neutral brown; RGB models additive mixing of light, where blue and yellow light sum to white. Software works in light, so a scheme generator's complement will be the yellow. If you want the painterly result, either use a tool with an RYB mode or rotate roughly 30° to 40° short of the mathematical complement — from hue 240 that lands you around hue 30, which renders as #ff8000, the orange you were expecting.
Should I generate palettes in OKLCH instead of HSL?
For anything where the members are meant to feel like peers, yes. Holding OKLCH lightness constant across a rotation gives you colours that actually look equally light, where holding HSL lightness constant gives you the 12.85-fold luminance spread between yellow and blue described above. OKLCH is supported in CSS as oklch() and is part of the CSS Color 4 specification, so you can author it directly rather than converting. Two practical cautions. First, OKLCH describes colours outside the sRGB gamut, so a chroma that works at one hue may be unreachable at another — browsers gamut-map, but the mapped result will have less chroma than you asked for, and a palette specified at a single high C will not come out evenly saturated. Keep C conservative, around 0.10 to 0.15, if you need every hue to survive. Second, OKLCH lightness is still not WCAG relative luminance, so equal-L palettes are not equal-contrast palettes. You still have to check each colour against each background it will meet. What OKLCH buys you is that the check will usually pass or fail for all members together, instead of one at a time for reasons nobody can explain.
Does a bigger hue rotation always mean more contrast?
No, and the counter-examples are easy to produce. Contrast under WCAG depends only on relative luminance, and hue rotation changes luminance in a way that has nothing to do with how far you rotated. From #2563eb the rotation that damages contrast least is +120° at 4.22:1, and the rotation that damages it most is +210° at 1.37:1 — but +180°, the largest possible rotation, lands at 1.99:1, between the two. The relationship is not monotonic because the luminance weights peak sharply in the green-yellow arc: any rotation that carries you into the region around hue 60 to 120 will raise luminance and, against a white background, lower contrast, whether you got there by turning 90° or 250°. The useful mental model is that hue rotation is a walk across a landscape whose height is luminance, with a mountain around yellow-green and a valley around blue. Distance walked tells you nothing about altitude reached. If two colours need to contrast, set their lightnesses apart deliberately; rotating hue alone is not a contrast strategy, it is a hue strategy.
How many colours does a scheme actually need?
Fewer hues and more lightness steps than most generators suggest. A working interface palette is typically one brand hue, one neutral ramp and at most one accent, with each of those existing in several lightness variants — a light surface tint, a mid fill, and a dark text-safe version. That last one is the reason the count grows: as shown above, a single hue at a single lightness cannot serve as both a background wash and readable text, because one of those roles needs 4.5:1 against white and the other needs to be barely visible against it. The generator's job is to find the hue relationships; your job is to give each hue the two or three lightness steps its roles require. A four-hue scheme with one variant each will fail somewhere, and a two-hue scheme with five variants each will not. If you are counting, aim for something like a nine-step neutral ramp, five steps of the brand hue and five of the accent, and check the ones intended for text against every surface they can appear on.

Articles you may find interesting

All guides
ExplainerGradients, Banding, and Why the Middle Looks MuddyInterpolating in sRGB averages gamma-encoded numbers, so the midpoint of red to green is #808000 when the half-light answer is #bcbc00 — 57.2% too little light. Banding is a separate arithmetic problem: 8 bits give 256 steps, and a dark gradient may have only 28 of them. Here is both, computed.ExplainerContrast Ratio: How WCAG Actually Computes ItThe WCAG ratio is (L1 + 0.05) ÷ (L2 + 0.05), and L is relative luminance, not brightness. Green carries 71.52% of it and blue 7.22%, which is why pure blue on white passes at 8.59:1 while mid grey fails at 3.95:1. Here is the whole computation, run end to end.How-toHEX vs RGB: How to Read and Convert ColoursHEX and RGB are two notations for the same colours. Here's how to read each, convert between them, and add transparency.GuideWhat a Minifier Can Remove, and What It Must NotMinification has to preserve meaning, and the interesting part is the whitespace that carries meaning: the descendant combinator, the spaces inside calc(), the gap between two inline elements. Measured here on real files, including how much brotli was going to save you anyway.ExplainerHow Instagram Fonts Actually Work (They Are Not Fonts)Nothing is styled. Each letter is swapped for a different Unicode character that happens to look bold or cursive — which is why a screen reader reads the result as gibberish and some devices show empty boxes.ExplainerWhat an Availability Percentage Actually AllowsThree nines sounds like a promise until you divide it into minutes. What 99.9 % buys per year, per month, per week and per day; why the measurement window matters far more than the extra nine; and the two different months this tool uses for the same slug.

Related tools

Sources

Spotted a mistake in this article?