Mert Özen Further With Every Line
Frontend Sass/SCSS — #09

Sass Built-in Modules 2: Nested Token Maps, deep-merge and Color-Space-Aware Color Derivation with sass:map and sass:color

Mert Özen Nov 15, 2026 13 min 9 views
Sass Built-in Modules 2: Nested Token Maps, deep-merge and Color-Space-Aware Color Derivation with sass:map and sass:color

Override a nested token map with map.deep-merge(), not map.merge(), derive color tones in OKLCH instead of HSL and pass the result through color.to-gamut(); otherwise inner keys silently disappear, the same step varies by 8 to 14 points between colors, and the browser cuts out-of-gamut colors. We add OKLCH-derived hover, active and subtle tones and an institutional portal theme built with deep-merge to Bookstore.

Version note: The examples in this post were tested with Dart Sass 1.108.2, Angular 22.2 (TypeScript 6.0; Sass 1.104.1 inside the Angular CLI) and the Chromium build of Playwright 1.65.

Guess First

The institutional portal of Bookstore, the online bookstore used as the shared example across this site's series, is the area where schools and companies place bulk orders. It will use the same design with a different brand color. The default token map and the portal's only change:

@use "sass:map";

$defaults: (
    color: (
        brand: #b45309,
        surface: #ffffff,
        text: #1c1917,
    ),
    radius: 8px,
);

// the institutional portal only changes the brand color
$institutional: map.merge($defaults, (color: (brand: #1d4ed8)));

.portal-header {
    background-color: map.get($institutional, color, surface);
    color: map.get($institutional, color, text);
    border-bottom: 2px solid map.get($institutional, color, brand);
    border-radius: map.get($institutional, radius);
}

How many declarations end up in the compiled .portal-header rule? Does the compiler warn about anything?

In the previous post we worked with numbers and lists through sass:math, sass:string and sass:list. In this post we move to the two most used built-in modules: sass:map for nested token maps and sass:color, which has understood color spaces since 1.79.0. At the end we add hover and active tones derived in OKLCH and an institutional portal theme to Bookstore's Angular client (bookstore-web).

sass:map: Reading and Writing Along a Key Path

The answer to the guess:

.portal-header {
  border-bottom: 2px solid #1d4ed8;
  border-radius: 8px;
}

No background and no text color; no warning either. map.merge() only merges at the top level: the value of the color key, the inner map with three colors, was completely replaced by the new map with one color. surface and text are gone, map.get() returns null for them, and Sass silently drops a declaration whose value is null. The portal header loses its white background and dark text color, and only someone looking at the page notices.

First let's look at the reading and writing side of the module. In the fifth post of the series we used theme maps one level deep, as in map.get($themes, light); map.get() and the other functions accept several keys and follow a path through a nested map:

@use "sass:map";

$tokens: (
    color: (
        brand: (base: #b45309, hover: #92400e),
        surface: #ffffff,
    ),
    radius: 8px,
);

.map-demo {
    a: map.get($tokens, color, brand, hover);
    b: map.get($tokens, color, brand, focus);
    c: map.has-key($tokens, color, brand, base);
    d: map.keys(map.get($tokens, color));
    e: map.values(map.get($tokens, color, brand));
    f: map.keys(map.get(map.set($tokens, color, brand, focus, #78350f), color, brand));
    g: map.keys(map.remove($tokens, radius));
    h: map.keys(map.get(map.deep-remove($tokens, color, brand, hover), color, brand));
    i: map.keys(map.get($tokens, color, brand));
}
.map-demo {
  a: #92400e;
  c: true;
  d: brand, surface;
  e: #b45309, #92400e;
  f: base, hover, focus;
  g: color;
  h: base;
  i: base, hover;
}

Line b is missing from the output: a path that does not exist is not an error, it is null. map.set() takes the last argument as the value and the ones before it as the path, creating intermediate maps if they do not exist. map.remove() only removes from the top level, map.deep-remove() removes the key at the end of a path. The last line matters: after the changes in f and h, $tokens itself did not change. Maps in Sass are immutable like lists; every function returns a new map, and if you do not assign the result to a variable, the change is lost.

If you try to write a map straight to CSS, compilation stops: (base: #b45309, hover: #92400e, focus: #78350f) isn't a valid CSS value. That is why I read the results above with map.keys().

merge and deep-merge

The fix for the problem in the guess is map.deep-merge(): it merges keys whose values are maps on both sides recursively.

$override: (color: (brand: #1d4ed8));

$shallow: map.merge($defaults, $override);
$deep: map.deep-merge($defaults, $override);

.merge-demo {
    shallow: map.keys(map.get($shallow, color));
    deep: map.keys(map.get($deep, color));
    deep-brand: map.get($deep, color, brand);
    defaults-brand: map.get($defaults, color, brand);
}
.merge-demo {
  shallow: brand;
  deep: brand, surface, text;
  deep-brand: #1d4ed8;
  defaults-brand: #b45309;
}

map.merge() is not a wrong function either; it is the right tool for adding a few keys to a flat map or for replacing an inner map completely on purpose. The rule is simple: if token maps are nested, overrides always go through deep-merge.

Merging Overrides with Defaults Through with()

In the fourth post of the series we configured a module from outside with !default and with(); there every setting was a separate variable. As the number of tokens grows, taking a single $config map and deep-merging it with the defaults scales better. The weak spot of this setup is typos: a key spelled brnad is silently added by deep-merge and changes nothing. I added a small function to the module that checks for this:

@use "sass:map";
@use "sass:meta";

$-defaults: (
    color: (
        brand: #b45309,
        surface: #ffffff,
        text: #1c1917,
    ),
    radius: 8px,
);

// overrides from the caller: @use "portal-theme" with ($config: (...))
$config: () !default;

// every key in the override must exist in the defaults: catches typos
@function -check($override, $defaults, $path: "") {
    @each $key, $value in $override {
        @if not map.has-key($defaults, $key) {
            @error "Unknown theme key '#{$path}#{$key}'. Expected one of: #{map.keys($defaults)}";
        }
        $default: map.get($defaults, $key);
        @if meta.type-of($value) == map and meta.type-of($default) == map {
            $_: -check($value, $default, "#{$path}#{$key}.");
        }
    }
    @return true;
}

$_: -check($config, $-defaults);
$theme: map.deep-merge($-defaults, $config);
@use "sass:map";
@use "portal-theme" with (
    $config: (
        color: (
            brand: #1d4ed8,
        ),
    )
);

.portal-header {
    background-color: map.get(portal-theme.$theme, color, surface);
    color: map.get(portal-theme.$theme, color, text);
    border-bottom: 2px solid map.get(portal-theme.$theme, color, brand);
    border-radius: map.get(portal-theme.$theme, radius);
}
.portal-header {
  background-color: #ffffff;
  color: #1c1917;
  border-bottom: 2px solid #1d4ed8;
  border-radius: 8px;
}

All four declarations are there. Writing brnad instead of brand:

Error: "Unknown theme key 'color.brnad'. Expected one of: brand, surface, text"
   ╷
24 │             $_: -check($value, $default, "#{$path}#{$key}.");
   │                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
   ╵
  09/_portal-theme.scss 24:17  -check()
  09/_portal-theme.scss 30:5   @use
  09/portal-typo.scss 2:1      root stylesheet

A few details: functions can call themselves, which is how inner maps are checked at every level. Sass has no way to call a function that returns a value without using the result; that is why the $_: assignment is there. meta.type-of() returns the type of a value as a string; we will look at sass:meta and argument validation in detail when we write our own library. The @use line in the stack trace also shows that the check runs while the module loads, that is, once.

sass:color: Color-Space-Aware Functions

With Dart Sass 1.79.0, colors started to carry a color space, and the functions in sass:color gained a $space parameter. With Bookstore's brand color:

@use "sass:color";

$brand: #b45309;

.color-demo {
    a: color.adjust($brand, $lightness: -10%, $space: oklch);
    b: color.scale($brand, $lightness: 40%, $space: oklch);
    c: color.change($brand, $alpha: 0.5);
    d: color.channel($brand, "hue", $space: oklch);
    e: color.to-space($brand, oklch);
    f: color.mix($brand, white, 50%);
    g: color.mix($brand, white, 50%, $method: oklch);
    h: color.is-legacy($brand) color.is-legacy(color.to-space($brand, oklch));
    i: color.same(#b45309, rgb(180 83 9));
    j: color.is-in-gamut(oklch(70% 0.3 50deg), $space: rgb);
    k: color.to-gamut(oklch(70% 0.3 50deg), $space: rgb, $method: local-minde);
    l: oklch(70% 0.3 50deg);
}
.color-demo {
  a: hsl(27.5115255796, 146.3097269007%, 23.37604166%);
  b: rgb(94.3990869196%, 54.1097508392%, 30.8755981583%);
  c: rgba(180, 83, 9, 0.5);
  d: 48.9975238238deg;
  e: oklch(55.5283112356% 0.1455052079 48.9975238238deg);
  f: rgb(85.2941176471%, 66.2745098039%, 51.7647058824%);
  g: rgb(86.9864141124%, 66.4289667426%, 55.6100374957%);
  h: true false;
  i: true;
  j: false;
  k: oklch(70.1845509996% 0.2006638896 44.9981853813deg);
  l: oklch(70% 0.3 50deg);
}

The difference between the three functions is familiar from the fourth post: adjust adds a fixed amount to a channel, scale moves by a percentage of the remaining distance, change sets the channel to a value directly. $space: oklch says which space the math happens in; the result is converted back to the input's space. #b45309 is a legacy color (is-legacy is true for rgb, hsl, hwb and hex), so a and b came out in the rgb family again. color.channel() reads a channel in any space; it is what we used in the third post to pick the badge text color. color.mix() keeps the old behavior by default; with $method: oklch the mix happens in a perceptual space and the result is slightly different. color.same() compares two colors regardless of how they are written; == does not treat the same color in different spaces as equal.

The 146% saturation on line a is not a typo; it is the most important detail of this post, and we will come back to it shortly.

The old functions still work but raise two separate warnings:

DEPRECATION WARNING [color-functions]: darken() is deprecated. Suggestions:

color.scale($color, $lightness: -26.9841269841%)
color.adjust($color, $lightness: -10%)

More info: https://sass-lang.com/d/color-functions

Functions like darken() and transparentize() become errors in Dart Sass 2.0 (the seventh post of the series). The replacements the warning suggests reproduce the old result exactly, which means they work in HSL. That is the right choice during a migration; in new code you need to decide consciously which space you derive in.

HSL or OKLCH? Perceived Lightness and Gamut

Lightness in HSL is not the same as the brightness the eye sees. To measure this, I took three Bookstore colors (the brand, the institutional portal's blue and the in-stock green), darkened them by 10 points and lightened them by 20 points, first in HSL and then in OKLCH, and read the OKLCH lightness, the perceived lightness, of each result:

@use "sass:color";
@use "sass:math";

// Bookstore's brand, the institutional portal's brand, the in-stock green
$brands: ("amber": #b45309, "blue": #1d4ed8, "green": #15803d);

// perceived lightness: the oklch "l" channel
@function lightness($c) {
    @return math.round(color.channel($c, "lightness", $space: oklch));
}

@each $name, $c in $brands {
    .#{$name} {
        base: lightness($c);
        hsl-minus-10: lightness(color.adjust($c, $lightness: -10%, $space: hsl));
        oklch-minus-10: lightness(color.adjust($c, $lightness: -10%, $space: oklch));
        hsl-plus-20: lightness(color.adjust($c, $lightness: 20%, $space: hsl));
        oklch-plus-20: lightness(color.adjust($c, $lightness: 20%, $space: oklch));
    }
}
ColorStartHSL −10OKLCH −10HSL +20OKLCH +20
#b4530956%44% (−12)46% (−10)73% (+17)76% (+20)
#1d4ed849%41% (−8)39% (−10)67% (+18)69% (+20)
#15803d53%39% (−14)43% (−10)77% (+24)73% (+20)

In HSL the same "−10" step darkens the green by 14 points and the blue by 8. In a design system this means hover tones produced by the same rule are obvious on some colors and hard to notice on others. In OKLCH the step is the same for every color. That is why Bookstore derives its tones in OKLCH.

Now the 146%. Darkening #b45309 by 10 points in OKLCH gives a color outside sRGB, the default color space of screens: color.is-in-gamut() says false. When Sass converts the result back to the legacy space it does not fit it into range; it writes the channels as they are, and the result is an hsl() with 146% saturation. What does the browser do? I gave three values to Chromium:

@use "sass:color";

$brand: #b45309;
$hover: color.adjust($brand, $lightness: -10%, $space: oklch);

.gamut-demo {
    raw: $hover;
    in-gamut: color.is-in-gamut($hover);
    mapped: color.to-gamut($hover, $method: local-minde);
    clipped: color.to-gamut($hover, $method: clip);
}
node scripts/09-gamut.mjs
raw      rgb(119, 55, 0)
mapped   rgb(147, 52, 0)
clipped  rgb(147, 52, 0)

The browser cuts 146% down to 100% and paints a quite different color: the OKLCH lightness of rgb(119, 55, 0) is 41%. We wanted 46%; the 10-point step turned into 15 points. color.to-gamut() brings the color into sRGB; local-minde is the method the CSS Color 4 specification currently recommends: it lowers chroma in OKLCH while looking for the closest color inside sRGB. clip cuts each channel to its limit. For this color both gave the same result; they differ on saturated blues and greens. The rule: every color derived in OKLCH and converted back to a legacy space must go through color.to-gamut(). Sass 1.x does not do it on its own.

Compile Time or the Browser?

The same hover tone can also be produced in the browser, with the relative color syntax and color-mix() covered in the color post of the CSS3 series:

:root { --color-brand: #b45309; }
.relative { color: oklch(from var(--color-brand) calc(l - 0.08) c h); }
.mix { color: color-mix(in oklch, var(--color-brand), black 15%); }
relative  oklch(0.475288 0.145526 48.9931)
mix       oklch(0.471995 0.123697 48.9931)

I decide with one question: is the base color known at compile time? Bookstore's brand and the institutional portal's color are fixed in the design file; producing their tones in Sass means sending ready values to the browser, getting the same result in every browser and being able to measure contrast on the compiled CSS. If the color arrives at runtime (a customer's own logo color, a user preference, a white-label theme), Sass never sees it; tone derivation has to happen in the browser, with relative color or color-mix(). Mixing the two is possible too: Sass produces the fixed tones, CSS computes for the single value that arrives at runtime.

In Bookstore: Brand Tones and an Institutional Portal Theme

In bookstore-web I put the two functions that produce tones into abstracts/_colors.scss:

@use "sass:color";
@use "sass:map";

// a tone of $color: lightness moved in oklch, then mapped back into sRGB
@function tone($color, $lightness: 0%, $chroma: 0%) {
    $result: color.adjust($color, $lightness: $lightness, $space: oklch);
    $result: color.scale($result, $chroma: $chroma, $space: oklch);
    @return color.to-gamut($result, $space: rgb, $method: local-minde);
}

// adds <key>-hover, <key>-active and <key>-subtle to a theme map;
// $direction: -1 darkens (light theme), 1 lightens (dark theme)
@function with-states($theme, $key, $direction) {
    $base: map.get($theme, $key);
    // a very light background tone in the light theme, a very dark one in the dark theme
    $subtle-lightness: 96%;
    @if $direction > 0 {
        $subtle-lightness: 28%;
    }
    $subtle: color.change($base, $lightness: $subtle-lightness, $space: oklch);
    @return map.merge($theme, (
        "#{$key}-hover": tone($base, $direction * 8%),
        "#{$key}-active": tone($base, $direction * 14%),
        "#{$key}-subtle": tone($subtle, $chroma: -60%),
    ));
}

$themes in _tokens.scss is now built in two steps: the base colors sit in a private map, and with-states() adds three new keys to each theme. The institutional portal changes only the brand color and is written over the base map with deep-merge:

// the institutional portal only changes the brand color
$-institutional: (
    light: (brand: #1d4ed8),
    dark: (brand: #60a5fa),
);

@function -with-brand-states($themes) {
    @return (
        light: colors.with-states(map.get($themes, light), brand, -1),
        dark: colors.with-states(map.get($themes, dark), brand, 1),
    );
}

$themes: -with-brand-states($-base);
$institutional-themes: -with-brand-states(map.deep-merge($-base, $-institutional));

The color-tokens() mixin from the fifth post did not change at all; it turns the new keys into custom properties with light-dark() as well. themes/_index.scss only got a block for the portal:

html {
    // ...
    // institutional portal: same tokens, different brand
    &.institutional {
        @include color-tokens($institutional-themes);
    }
}

The hover border of BookCard became var(--color-brand-hover) and the featured card's background var(--color-brand-subtle). I measured four states with Playwright (for the portal the institutional class is added to <html>, for hover the pointer moves over the second card):

node scripts/09-web-tones.mjs
light         brand=rgb(180, 83, 9)    hover=rgb(153, 59, 0)    subtle=rgb(255, 233, 215)
light portal  brand=rgb(29, 78, 216)   hover=rgb(8, 50, 188)    subtle=rgb(229, 242, 255)
dark          brand=rgb(245, 158, 11)  hover=rgb(255, 186, 81)  subtle=rgb(61, 34, 0)
dark portal   brand=rgb(96, 165, 250)  hover=rgb(129, 191, 255) subtle=rgb(20, 42, 68)

In the light theme hover gets darker, in the dark theme lighter; the portal class only changes the brand family. The institutional portal is not a separate product but the same Bookstore: one class and six tokens of difference.

In Production

Watch the size of the CSS you generate. The global style sheet went from 10.74 kB to 11.71 kB. Part of that is the new tones; part of it is the html.institutional block writing the unchanged tokens (surface, text…) again. With five base colors that is negligible; in a system with a hundred tokens you need to generate only the keys that differ per portal (see the experiment below).

Measure contrast on the compiled values. OKLCH steps are perceptually equal, but that does not guarantee a hover tone has enough contrast against its background. Because the tones are known at compile time, putting a contrast check into the build is easy; the measuring tools are in the Accessibility (a11y) series.

Long decimal rgb() values are normal. Since 1.79.0 Sass does not round channels; rgb(60.1581403468%, 22.9667090617%, 0%) is valid CSS and the browser handles it at its own precision. If you need readability, writing the result as OKLCH with color.to-space($c, oklch) is an option, but then you need to check support in older browsers.

Common Pitfalls

Producing tones with lighten()/darken(), that is, in HSL. As the table above shows, the same step varies between 8 and 14 points from color to color. On top of that, these functions are errors in Dart Sass 2.0.

Overriding a nested map with map.merge(). As in the guess, the inner map's other keys disappear and declarations with a null value are dropped without a warning. Overrides always go through map.deep-merge(), plus a check that catches unknown keys.

Deriving in OKLCH and forgetting to-gamut(). Sass writes the out-of-gamut color as it is, the browser cuts it, and a 10-point step becomes 15 points. This is hard to see by eye; it only shows up when you measure.

Using color names as unquoted map keys. In the map (blue: #1d4ed8, green: #15803d), blue is not a string but a color. When you generate class names with interpolation, Sass warns:

WARNING: You probably don't mean to use the color value blue in interpolation here.
It may end up represented as blue, which will likely produce invalid CSS.
Always quote color names when using them as strings or map keys (for example, "blue").

In the current output the class name still comes out as .badge-blue, but as the warning says, this is how a color value happens to be written and there is no guarantee it stays that way. Always quote the keys: ("blue": #1d4ed8).

A Small Experiment

★☆☆ Add the yellow from the stock badge (#fbbf24) to 09/tones.scss. How much does the perceived lightness change when you lighten it by 20 points in HSL? Compare the result with the three colors in the table.

★★☆ In 09/gamut.scss, set $brand to #15803d and the lightness step to +25%. Do local-minde and clip give different results? Read the OKLCH lightness and hue of both with color.channel().

★★★ Add a second mixin next to color-tokens(): color-token-overrides($base, $override). It should generate custom properties only for the keys in $override whose value differs from $base. Rewrite the html.institutional block with it and measure the difference in global CSS size.

In the next post we go back to generating rules from maps, this time with a full API: up, down, between and only mixins from a breakpoint map, @content(args), a container query mixin, and generating responsive utility classes while keeping the CSS size under control.