Relative Colours & Wide-gamut Colour
A real website needs many versions of its brand colour: a darker shade for hover, a pale tint for backgrounds, a see-through version for focus rings, and a matching text colour. Typing each one by hand means a rebrand turns into a hunt through hundreds of hex codes.
In this lesson you will use relative colour syntax to build every shade from one variable, learn what wide-gamut colour is and how to use the extra-vivid colours of modern screens with a safe fallback, and meet the new contrast-color() function. You should already know oklch() and color-mix() from CSS Colours.
Relative colour syntax: from
A relative colour starts from an existing colour, called the origin, and lets you keep or change each channel. You write a normal colour function, then the keyword from, then the origin colour, then the channels:
:root {
--brand: #0f766e;
}
.same { color: oklch(from var(--brand) l c h); } /* exactly the brand colour */
.darker { color: oklch(from var(--brand) calc(l - 0.15) c h); }
.faded { color: oklch(from var(--brand) l c h / 0.4); } /* 40% opaque */Inside the function, the letters l, c and h are channel keywords. The browser first converts the origin into the output colour space, here OKLCH, and then each keyword stands for that channel's value as a plain number. The keyword alpha stands for the origin's transparency.
Two details trip people up:
- In
oklch(), the lightness keywordlis a number from 0 to 1, not a percentage. Socalc(l - 0.15)is right andcalc(l - 15%)is invalid. - The origin can be any colour format: hex,
rgb(), a named colour or a custom property. The browser converts it for you.
The function you choose decides which channels you can adjust. rgb(from ...) gives you r, g and b; hsl(from ...) gives h, s and l. For shades that look even, use oklch(from ...), because its lightness matches human vision.
Building a palette from one colour
Here one custom property creates a whole set of swatches. Change --brand to any colour and every swatch follows.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Palette from one colour</title>
<style>
:root { --brand: #c2410c; }
body { font-family: system-ui, sans-serif; padding: 16px; }
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; }
.row div { padding: 24px 8px; border-radius: 8px; font-size: 0.85rem; text-align: center; }
.tint-2 { background: oklch(from var(--brand) 0.95 calc(c * 0.3) h); }
.tint-1 { background: oklch(from var(--brand) 0.82 calc(c * 0.6) h); }
.base { background: var(--brand); color: white; }
.shade-1 { background: oklch(from var(--brand) calc(l - 0.12) c h); color: white; }
.shade-2 { background: oklch(from var(--brand) calc(l - 0.24) c h); color: white; }
.glass { background: oklch(from var(--brand) l c h / 0.25); }
.partner { background: oklch(from var(--brand) l c calc(h + 180)); color: white; }
</style>
</head>
<body>
<p>Brand colour of Rohan's Sweet Shop, Rohtak. Change <code>--brand</code> and every swatch updates.</p>
<div class="row">
<div class="tint-2">tint 2</div>
<div class="tint-1">tint 1</div>
<div class="base">base</div>
<div class="shade-1">shade 1</div>
<div class="shade-2">shade 2</div>
<div class="glass">25% alpha</div>
<div class="partner">opposite hue</div>
</div>
</body>
</html>The tints set lightness to a fixed high value and reduce chroma, because very light colours with full chroma look harsh. The shades subtract from the brand's own lightness. Adding 180 to the hue gives the colour on the opposite side of the colour wheel, a quick way to find an accent.
Hover and focus states from one variable
The biggest win comes in components. A button can take its colour from one custom property and work out its hover, active and focus colours itself:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Relative colour buttons</title>
<style>
body { font-family: system-ui, sans-serif; padding: 24px; display: flex; gap: 12px; flex-wrap: wrap; }
.btn {
--btn: #1d4ed8;
padding: 10px 18px;
border: 0;
border-radius: 8px;
font: inherit;
color: white;
background: var(--btn);
cursor: pointer;
}
.btn:hover { background: oklch(from var(--btn) calc(l - 0.08) c h); }
.btn:active { background: oklch(from var(--btn) calc(l - 0.16) c h); }
.btn:focus-visible {
outline: 4px solid oklch(from var(--btn) l c h / 0.45);
outline-offset: 2px;
}
.btn--success { --btn: #15803d; }
.btn--danger { --btn: #b91c1c; }
</style>
</head>
<body>
<button class="btn" type="button">Book ticket</button>
<button class="btn btn--success" type="button">Pay ₹1,299</button>
<button class="btn btn--danger" type="button">Cancel booking</button>
</body>
</html>Each variant only sets --btn. Hover, press or tab to the buttons: every state colour is calculated from that one value. With color-mix() you could get similar results by mixing with black or transparent, and that is a fine choice too. Relative colour gives you finer control, because you change exactly one channel and keep the others.
Wide-gamut colour
A colour gamut is the range of colours a screen or a colour space can show. Hex, rgb() and hsl() all describe colours in sRGB, the gamut of ordinary screens from the 1990s onwards. Many modern phones and laptops can show a wider gamut called Display P3, with deeper reds, greens and oranges.
You can reach those colours in two ways:
.badge {
background: #f97316; /* sRGB fallback, works everywhere */
background: color(display-p3 1 0.42 0); /* P3 orange: channels from 0 to 1 */
}
.badge-alt {
background: oklch(70% 0.22 45); /* high chroma can go beyond sRGB */
}If a screen cannot show a colour, the browser maps it to the nearest colour the screen can show, so the page never breaks. Still, colours outside sRGB can look different from screen to screen, so check important brand colours on a normal monitor as well. When you want different values only on wide-gamut screens, use the color-gamut media feature:
:root { --accent: #16a34a; }
@media (color-gamut: p3) {
:root { --accent: oklch(66% 0.24 145); }
}color(), oklch(), color-mix() and the color-gamut media query are all Baseline Widely available.
contrast-color(): automatic black or white text
Badges and tags often take their background colour from data, for example a category colour chosen by an admin. contrast-color() takes a colour and returns either white or black, whichever contrasts more with it:
.tag {
background: var(--tag-bg);
color: contrast-color(var(--tag-bg));
}It works well for very light or very dark backgrounds. For mid-tone backgrounds, such as a medium blue, the result may still be hard to read for small text, so it does not guarantee that you pass accessibility checks. Test real colours with the contrast checker in DevTools.
Common mistakes
- Using percentages in
calc()with channel keywords. Channel keywords are numbers, socalc(l + 10%)is invalid and the whole declaration is dropped. Writecalc(l + 0.1)inoklch(). - Forgetting the fallback line. In an older browser an invalid colour is ignored, and the element may end up with no background at all. Put a plain colour on the line before.
- Pushing chroma too high for text. Very saturated colours can look different between screens and often fail contrast. Keep text colours calmer and save vivid P3 colours for accents.
- Trusting
contrast-color()blindly. It picks black or white, but a mid-tone background can still fail. Check the ratio.
Key points
oklch(from <origin> l c h)copies a colour; change any channel withcalc()to make shades, tints and hue shifts.- Channel keywords are plain numbers; in
oklch(), lightness runs from 0 to 1. - Add
/ <alpha>to a relative colour for a transparent version, such as a focus ring. - One custom property plus relative colours gives every button state, so variants only set one variable.
color(display-p3 ...)and high-chromaoklch()reach wide-gamut colours; keep an sRGB fallback and use@media (color-gamut: p3)when needed.contrast-color()returns black or white for a background, but does not guarantee readable contrast.
Exercise
A school timetable app in Jaipur colours each subject from one custom property, such as --subject: #7c3aed for Maths. Build a .subject card that uses only that variable to create: a pale background tint, a 2px border that is slightly darker than the base colour, a heading in a dark shade of the same hue, and a small pill label with the full base colour and white text. Show two cards with different --subject values.
Show solution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Subject cards</title>
<style>
body { font-family: system-ui, sans-serif; padding: 16px; display: grid; gap: 12px; max-width: 24rem; }
.subject {
background: #f5f5f5;
background: oklch(from var(--subject) 0.96 calc(c * 0.25) h);
border: 2px solid oklch(from var(--subject) calc(l - 0.1) c h);
border-radius: 10px;
padding: 12px 16px;
}
.subject h2 { margin: 0 0 8px; color: oklch(from var(--subject) 0.3 c h); }
.pill { background: var(--subject); color: white; padding: 2px 10px; border-radius: 999px; font-size: 0.8rem; }
</style>
</head>
<body>
<div class="subject" style="--subject: #7c3aed">
<h2>Maths</h2><span class="pill">Period 1, Room 12</span>
</div>
<div class="subject" style="--subject: #0e7490">
<h2>Science</h2><span class="pill">Period 2, Lab B</span>
</div>
</body>
</html>Every colour on each card comes from --subject. The grey line before the tint is a fallback for browsers without relative colour support.
Quick Quiz
1. What does oklch(from var(--brand) l c h / 0.3) produce?
The channels are copied unchanged and / 0.3 sets the alpha.
2. Why is oklch(from red calc(l + 10%) c h) invalid?
Channel keywords resolve to numbers; add a number such as 0.1 instead.
3. What happens when you use a Display P3 colour on a screen that can only show sRGB?
Out-of-gamut colours are mapped into the screen's gamut, so the page still works.
4. What does contrast-color(#fefce8) return?
It returns white or black, whichever contrasts more with the input; a very pale yellow needs black text.
Learn CSS with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
