Colour Functions & Colour Spaces
Brand guidelines usually give you one or two colours. A real interface needs many more: a lighter tint for backgrounds, a darker shade for hover states, a faded version for disabled buttons. The sass:color module calculates these from your brand colours while compiling, so the whole palette changes when the brand colour does.
In this lesson you will use color.scale(), color.adjust(), color.mix() and color.change(), learn how Sass handles modern colour spaces such as oklch (supported since Dart Sass 1.79), generate a palette, and see which jobs native CSS colour functions can now do in the browser.
scale, adjust, mix and change
Four functions cover most needs. Load the module first with @use "sass:color";.
@use "sass:color";
$brand: #cc6699;
.swatches {
--darker: #{color.scale($brand, $lightness: -25%)};
--lighter: #{color.scale($brand, $lightness: 40%)};
--adjusted: #{color.adjust($brand, $lightness: -10%)};
--tint: #{color.mix($brand, white, 20%)};
--faded: #{color.change($brand, $alpha: 0.5)};
}.swatches {
--darker: rgb(67.5%, 22.5%, 45%);
--lighter: rgb(88%, 64%, 76%);
--adjusted: rgb(75%, 25%, 50%);
--tint: rgb(96%, 88%, 92%);
--faded: rgba(204, 102, 153, 0.5);
}color.scale()moves a channel a percentage of the way to its limit.$lightness: 40%means "40% of the way from the current lightness to white". It is the best default, because it never overshoots: a dark colour and a light colour both change by a sensible amount.color.adjust()changes a channel by a fixed amount:$lightness: -10%subtracts 10 points. It can push a light colour to pure white or a dark one to black.color.mix($a, $b, $weight)blends two colours;20%means 20% of the first colour, 80% of the second.color.change()sets a channel to an exact value, here the alpha (opacity).
Sass wrote the results as rgb() with percentages. Since Dart Sass 1.79, Sass no longer rounds colour channels to whole numbers, and for legacy colours with fractional channels it uses percentages so that every browser reads them correctly. The exact numbers look odd, but they are the most precise answer.
Old: darken() and lighten()
Older code is full of global colour functions:
.btn:hover {
background: darken($brand, 10%);
border-color: lighten($brand, 20%);
}These are deprecated twice over: global functions are deprecated, and darken(), lighten(), saturate(), desaturate(), adjust-hue(), opacify(), transparentize() and the channel readers like red() and lightness() are deprecated as colour functions. Their replacements:
| Old | New |
|---|---|
darken($c, 10%) | color.adjust($c, $lightness: -10%) (same result) or color.scale($c, $lightness: -15%) (often nicer) |
lighten($c, 10%) | color.adjust($c, $lightness: 10%) or color.scale(...) |
transparentize($c, 0.3) | color.adjust($c, $alpha: -0.3) |
lightness($c) | color.channel($c, "lightness", $space: hsl) |
mix($a, $b) | color.mix($a, $b) |
The sass-migrator color command makes these changes for you.
Modern colour spaces
CSS Color 4 added colour spaces beyond plain sRGB, such as oklch(), lab() and color(display-p3 ...). oklch is especially useful for design systems: its L (lightness), C (chroma, roughly "colourfulness") and H (hue angle) behave the way people perceive colour, so two colours with the same L look equally light.
Since Dart Sass 1.79, every colour remembers the space it was written in, and Sass keeps it in that space:
@use "sass:color";
$teal: oklch(55% 0.12 200);
$brand: #cc6699;
.modern {
--lighter: #{color.scale($teal, $lightness: 30%)};
--rotated: #{color.adjust($teal, $hue: 40deg)};
--brand-as-oklch: #{color.to-space($brand, oklch)};
}
@debug color.space($teal);
@debug color.is-legacy($brand);
@debug color.channel($brand, "lightness", $space: oklch);
@debug color.channel($brand, "hue", $space: hsl);line 12 DEBUG: oklch
line 13 DEBUG: true
line 14 DEBUG: 64.53704448083565%
line 15 DEBUG: 330deg
.modern {
--lighter: oklch(68.5% 0.12 200deg);
--rotated: oklch(55% 0.12 240deg);
--brand-as-oklch: oklch(64.5370444808% 0.141337542 350.5087175544deg);
}- Operations on an
oklchcolour return anoklchcolour. color.to-space()is the only function that converts a colour into another space.color.channel()reads one channel, and you say which space you mean, because "lightness" inhsland inoklchare different numbers.color.is-legacy()istruefor the old spaces (rgb,hsl,hwb).
Gamut: colours the screen cannot show
Wide colour spaces can describe colours that an ordinary sRGB screen cannot display. Such a colour is out of gamut. Browsers map it to the nearest colour they can show, but you can check and fix it while compiling:
@use "sass:color";
$leaf: oklch(62% 0.25 145);
$leaf-safe: color.to-gamut($leaf, $space: rgb, $method: local-minde);
@debug color.is-in-gamut($leaf, $space: rgb);
.tag-organic {
background: $leaf-safe;
}line 6 DEBUG: false
.tag-organic {
background: oklch(62.8833848418% 0.213976063 142.4953450414deg);
}local-minde is the gamut-mapping method recommended by the CSS specification: it lowers chroma until the colour fits, keeping lightness and hue as close as possible.
Generating a palette
Here is the pay-off: one brand colour, a map of steps, and a loop. Because the base colour is oklch, each step has evenly spaced lightness and the same hue:
@use "sass:color";
$brand: oklch(58% 0.16 350);
$steps: (100: 80%, 300: 45%, 500: 0%, 700: -25%, 900: -50%);
@each $step, $amount in $steps {
.bg-brand-#{$step} {
background: color.scale($brand, $lightness: $amount);
}
}.bg-brand-100 {
background: oklch(91.6% 0.16 350deg);
}
.bg-brand-300 {
background: oklch(76.9% 0.16 350deg);
}
.bg-brand-500 {
background: oklch(58% 0.16 350deg);
}
.bg-brand-700 {
background: oklch(43.5% 0.16 350deg);
}
.bg-brand-900 {
background: oklch(29% 0.16 350deg);
}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Generated palette</title>
<style>
body { font-family: system-ui, sans-serif; padding: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
div { width: 6.5rem; height: 5rem; border-radius: 8px; display: grid; place-items: end start; padding: 0.4rem; box-sizing: border-box; font-size: 0.85rem; }
/* compiled from the SCSS above */
.bg-brand-100 { background: oklch(91.6% 0.16 350deg); }
.bg-brand-300 { background: oklch(76.9% 0.16 350deg); }
.bg-brand-500 { background: oklch(58% 0.16 350deg); color: white; }
.bg-brand-700 { background: oklch(43.5% 0.16 350deg); color: white; }
.bg-brand-900 { background: oklch(29% 0.16 350deg); color: white; }
</style>
</head>
<body>
<div class="bg-brand-100">100</div>
<div class="bg-brand-300">300</div>
<div class="bg-brand-500">500</div>
<div class="bg-brand-700">700</div>
<div class="bg-brand-900">900</div>
</body>
</html>Common mistakes
- New code with
darken()andlighten(). Usecolor.scale()orcolor.adjust(). - Expecting a hex result. Sass prints the most precise form, often
rgb()with percentages oroklch(). Browsers read both. - Reading channels without a space.
color.channel($c, "lightness")on a hex colour is ambiguous; add$space: hslor$space: oklch. - Huge
color.adjust()steps.$lightness: 40%on an already light colour gives white.color.scale()avoids this.
Key points
- Load
sass:color; usecolor.scale()for proportional changes,color.adjust()for fixed steps,color.mix()to blend andcolor.change()to set a channel. darken(),lighten()and other old colour functions are deprecated;sass-migrator colorreplaces them.- Since Dart Sass 1.79, colours keep their colour space;
color.to-space()converts andcolor.channel()reads a channel in a named space. color.is-in-gamut()andcolor.to-gamut()find and fix colours an sRGB screen cannot show.- Generate palettes in
oklchfor even steps; use nativecolor-mix()and relative colours when the base colour must change live.
Exercise
A Rohtak sweet shop's brand colour is saffron, oklch(75% 0.17 65). Generate classes .btn-saffron (background the brand colour), .btn-saffron:hover (scaled 15% darker), .btn-saffron:disabled (alpha 0.45) and .alert-saffron (background mixed 15% brand with white, border the brand colour). Use only sass:color functions and nesting.
Show solution
@use "sass:color";
$saffron: oklch(75% 0.17 65);
.btn-saffron {
background: $saffron;
&:hover {
background: color.scale($saffron, $lightness: -15%);
}
&:disabled {
background: color.change($saffron, $alpha: 0.45);
}
}
.alert-saffron {
background: color.mix($saffron, white, 15%);
border: 1px solid $saffron;
}color.scale() makes a proportional, not fixed, step darker, and color.change() sets the alpha exactly. Mixing an oklch colour with white returns a colour in the first colour's space.
Quick Quiz
1. Which function moves lightness a percentage of the remaining distance, so it never overshoots?
color.scale() works proportionally; color.adjust() adds a fixed amount.
2. What is the modern replacement for darken($c, 10%) with the same result?
There is no color.darken(); color.adjust() with a negative lightness matches the old function.
3. Since Dart Sass 1.79, what does color.scale() return for an oklch() colour?
Colours stay in the space they were defined in unless you call color.to-space().
4. When should you prefer native color-mix() over color.mix()?
Native functions are calculated live, so they follow theme changes; Sass results are fixed at build time.
Learn Sass 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
