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

Sass Built-in Modules 1: Unit Math, a px-to-rem Function and List Operations with sass:math, sass:string and sass:list

Mert Özen Nov 15, 2026 13 min 9 views
Sass Built-in Modules 1: Unit Math, a px-to-rem Function and List Operations with sass:math, sass:string and sass:list

Sass carries numbers with their units: it converts compatible units, fails when you mix rem and px, and since 1.96.0 turns 16px * 2px into a calc() that the browser drops; lists count from 1. We add to Bookstore a rem() that accepts unitless, px and pt values, a space() that reads a 0-based scale from a list, and font-stack(), and show escaping in class names and how unique-id() makes builds non-deterministic.

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

In Bookstore, the online bookstore used as the shared example across this site's series, the price tag computes three values from one base value:

@use "sass:math";

$base: 16px;

.price-tag {
    padding: $base * 0.5;
    line-height: math.div(24px, $base);
    width: $base * 2px;
}

Which of the three lines is a compile error? If none of them is, which ones does the browser use? A few years ago the answer was different; today's answer is in the first section.

In the previous post we prepared for Dart Sass 2.0, cleaned up deprecations and saw modules replace the global built-in functions. In this new block added to the series we move on to the toolbox you use every day. The first stop is number, string and list work: sass:math, sass:string and sass:list. At the end we add three small functions to Bookstore's Angular client (bookstore-web) for unit conversion, the spacing scale and the font list.

sass:math: How Units Are Computed at Compile Time

The answer to the guess:

npx sass 08/guess.scss
.price-tag {
  padding: 8px;
  line-height: 1.5;
  width: calc(32px * 1px);
}

Compilation finishes without an error. padding is 8px as expected and line-height is a unitless 1.5: dividing px by px cancels the units. The third line is the interesting one. 16px * 2px is a number with the unit "px squared". Older Sass versions failed here with isn't a valid CSS value; since Dart Sass 1.96.0, numbers with complex units like this are written to CSS as calc(). So what does the browser do? I gave the same CSS to Chromium and read which declarations survived:

node scripts/08-guess.mjs
kept by the browser: padding: 8px; line-height: 1.5;
computed: line-height=24px width=1248px

calc(32px * 1px) is not a length but a length squared; it is invalid for width, and the browser drops the declaration while parsing. The width falls back to auto. In other words, a mistake that used to be caught at compile time now reaches the browser silently. That is a good reason for every function that works with units to do its own checking.

Sass carries numbers together with their units and does arithmetic based on them. I collected the rules in one file:

@use "sass:math";

.units {
    // compatible units are converted: the result takes the left unit
    a: 1in + 6px;
    b: 6px + 1in;
    // a unitless number takes the other side's unit
    c: 16px * 2;
    // px / px: the units cancel out
    d: math.div(24px, 16px);
    // px * px is not a CSS value, but it is a valid Sass number
    e: math.unit(16px * 2px);
    f: math.is-unitless(math.div(24px, 16px));
    g: math.compatible(12pt, 1px);
    h: math.compatible(1rem, 1px);
    i: math.div(1, 3);
    j: math.percentage(math.div(1, 3));
    k: math.round(2.5) math.ceil(2.1) math.floor(2.9);
    l: math.clamp(4px, 12px, 8px);
    m: math.pow(1.25, 3) math.sqrt(2);
}
.units {
  a: 1.0625in;
  b: 102px;
  c: 32px;
  d: 1.5;
  e: "px*px";
  f: true;
  g: true;
  h: false;
  i: 0.3333333333;
  j: 33.3333333333%;
  k: 3 3 2;
  l: 8px;
  m: 1.953125 1.4142135624;
}

Sass only converts units that map to each other by a fixed ratio: absolute lengths like in, px, pt and cm, angles, times. rem and px are not compatible, because how many pixels a rem is depends on the user's browser setting and is unknown at compile time. That is why 1rem + 4px is an error:

Error: 1rem and 4px have incompatible units.
  ╷
1 │ .x { width: 1rem + 4px; }
  │             ^^^^^^^^^^
  ╵
  08/incompatible.scss 1:13  root stylesheet

The only way to divide is math.div(); we saw in the third and seventh posts of the series why the slash becomes a separator in Dart Sass 2.0. Results keep up to 10 decimal places (0.3333333333). math.clamp() takes its arguments in the same order as CSS clamp() (min, value, max), but works at compile time and only with compatible units.

A px-to-rem Function: Unitless, px and Compatible Units

In the third post of the series we wrote a rem() function; it accepted only px and checked math.unit($px) != "px". Measurements from the designer are sometimes unitless (rem(6)) and sometimes in pt. Checking with math.compatible() whether the unit converts to px is more accurate than checking the unit's name:

@use "sass:math";

$root-font-size: 16px !default;

// px (or a unit convertible to px) -> rem; a unitless number counts as px
@function rem($value) {
    @if math.is-unitless($value) {
        $value: $value * 1px;
    }
    @if not math.compatible($value, 1px) {
        @error "rem() expects px or a unitless number, got #{$value}";
    }
    @return math.div($value, $root-font-size) * 1rem;
}
@use "units" as *;

.book-card__title {
    font-size: rem(18px);
    margin-block-end: rem(6);
    letter-spacing: rem(0.75pt);
}
.book-card__title {
  font-size: 1.125rem;
  margin-block-end: 0.375rem;
  letter-spacing: 0.0625rem;
}

0.75pt was converted to 1px during math.div(); because division converts compatible units on its own, we wrote no conversion code. rem(1.5rem) breaks the build with our own message:

Error: "rem() expects px or a unitless number, got 1.5rem"
  ╷
2 │ .x { a: rem(1.5rem); }
  │         ^^^^^^^^^^^
  ╵
  08/rem-error.scss 2:9  root stylesheet

If someone passes a string like rem("18px"), the error is not ours but the built-in function's, and it points inside the function: Error: $number: "18px" is not a number. followed by 08/_units.scss 7:9 rem(). It works, but it is not the best message for the caller; we will look closely at checking argument types with sass:meta and raising meaningful errors when we write our own library.

Generating a Scale: math.pow, Rounding and Decimal Places

A common technique in typography is the modular scale: each font size is the previous one multiplied by a fixed ratio. With a ratio of 1.25 the scale goes 0.8, 1, 1.25, 1.5625 and so on. math.pow() produces it, but the results have long decimals. In Sass 1.x, math.round() only rounds to an integer, so you need a small helper for a number of digits:

@use "sass:list";
@use "sass:math";

// modular scale: each step is the previous one times 1.25
$ratio: 1.25;
$steps: (-1, 0, 1, 2, 3);

@function round-to($number, $digits: 3) {
    $factor: math.pow(10, $digits);
    @return math.div(math.round($number * $factor), $factor);
}

$type-scale: ();
@each $step in $steps {
    $type-scale: list.append($type-scale, round-to(math.pow($ratio, $step)) * 1rem);
}

@for $i from 1 through list.length($type-scale) {
    .text-#{$i} {
        font-size: list.nth($type-scale, $i);
    }
}

.raw {
    font-size: math.pow($ratio, 3) * 1rem;
}
.text-1 {
  font-size: 0.8rem;
}
...
.text-4 {
  font-size: 1.563rem;
}

.text-5 {
  font-size: 1.953rem;
}

.raw {
  font-size: 1.953125rem;
}

Note that list.append() returns a new list: lists in Sass are immutable, so in the loop we assign the result back to the same variable. Rounding is not needed by the browser, but for someone reading the compiled CSS, 1.563rem is clearer than 1.953125rem and easier to compare with the design document.

Sass Math or calc()?

Dart Sass understands calc() and performs the operation inside it at compile time when it can. When it cannot, it leaves the expression to the browser:

@use "units" as *;

$header-height: 64px;

.catalog {
    // everything is known at compile time: Sass computes it
    padding-block-start: calc($header-height + 16px);
    // 100vh is only known in the browser: Sass keeps calc()
    min-height: calc(100vh - $header-height);
    // fluid type: rem and vw mixed, the browser decides
    font-size: clamp(rem(16px), 0.875rem + 0.5vw, rem(20px));
}
.catalog {
  padding-block-start: 80px;
  min-height: calc(100vh - 64px);
  font-size: clamp(1rem, 0.875rem + 0.5vw, 1.25rem);
}

My rule: if a value depends on the viewport, the user's font size, a container or a custom property, write calc()/clamp() and leave it to the browser; if it only derives from design values, compute it in Sass. The clamp() on the last line is CSS's function, not Sass's: math.clamp() could not compute it because the units are incompatible. Sass only resolved the rem() calls and wrote the rest as is.

sass:string: String Work When Generating Class Names

In style code, string work is mostly needed when generating selectors and class names. I tried most of the module's functions in one file:

@use "sass:string";

$block: "stock-badge--low";

.string-demo {
    a: string.index($block, "--");
    b: string.slice($block, 1, string.index($block, "--") - 1);
    c: string.slice($block, -3);
    d: string.insert("book-card", "__title", -1);
    e: string.split("Inter, Segoe UI, system-ui", ", ");
    f: string.split("catalog.book-card.title", ".", 1);
    g: string.to-upper-case("isbn");
    h: string.unquote("Segoe UI") string.quote(Inter);
    i: string.length("Dune");
}
.string-demo {
  a: 12;
  b: "stock-badge";
  c: "low";
  d: "book-card__title";
  e: ["Inter", "Segoe UI", "system-ui"];
  f: ["catalog", "book-card.title"];
  g: "ISBN";
  h: Segoe UI "Inter";
  i: 4;
}

Indexes start at 1 and a negative index counts from the end (-3 is the last three characters). string.split() arrived in 1.57.0; it returns a bracketed list, and the third parameter says at most how many times to split. Once string.unquote("Segoe UI") removes the quotes, it becomes two separate words for font-family; font names with spaces need to stay quoted. to-upper-case() only changes ASCII letters; for Turkish characters the browser's text-transform together with the lang attribute gives the correct result.

If a class name contains a character with a special meaning in CSS, it needs escaping. When I wanted to generate percentage width utilities with names like .w-1/3:

Error: expected selector.
  ╷
1 │ .w-1/2 { width: 50%; }
  │     ^
  ╵
  08/escape-error.scss 1:5  root stylesheet
@use "sass:math";

$fractions: (2, 3);

@each $d in $fractions {
    @for $n from 1 to $d {
        // a slash is not allowed in a class name: escape it
        .w-#{$n}\/#{$d} {
            width: math.percentage(math.div($n, $d));
        }
    }
}
.w-1\/2 {
  width: 50%;
}

.w-1\/3 {
  width: 33.3333333333%;
}

.w-2\/3 {
  width: 66.6666666667%;
}

In HTML the class name is written without the escape (class="w-1/3"); the backslash only belongs in the CSS selector. I measured in Chromium inside a 600px box: w-1/3 came out at 200px and w-2/3 at 400px. Class names starting with a digit have the same problem; writing .2xl makes Sass say Expected identifier., and the correct form is .\32 xl. We will use the same kind of escape for the : in responsive variants when generating utilities.

string.unique-id() produces a unique name on every call; it is used so that @keyframes names do not collide. It has a side effect: when I compiled the same file twice, one run produced @keyframes uuqgxbs and the other @keyframes utx9nn3. When the output changes on every build, the content hash in the file name changes too, and browsers download the file again on every deploy even if nothing changed. In Bookstore we name keyframes by hand.

sass:list: 1-Based Indexes, Separators and slash

In Sass, 4px 8px is a list and so is Inter, system-ui; the only difference is the separator. With Bookstore's spacing scale and font list:

@use "sass:list";

$space-scale: 0 4px 8px 16px 24px 32px;
$fonts: "Inter", "Segoe UI", system-ui;

.list-demo {
    a: list.nth($space-scale, 1);
    b: list.nth($space-scale, -1);
    c: list.length($space-scale);
    d: list.index($space-scale, 16px);
    e: list.separator($space-scale) list.separator($fonts);
    f: list.append($fonts, sans-serif);
    g: list.join($fonts, (Arial, sans-serif));
    h: list.join(4px 8px, 16px 24px, $separator: comma);
    i: list.separator(16px) list.separator((16px,));
    j: list.zip(color background-color, 150ms 300ms);
    k: list.slash(1, -1);
}
.list-demo {
  a: 0;
  b: 32px;
  c: 6;
  d: 4;
  e: space comma;
  f: "Inter", "Segoe UI", system-ui, sans-serif;
  g: "Inter", "Segoe UI", system-ui, Arial, sans-serif;
  h: 4px, 8px, 16px, 24px;
  i: space comma;
  j: color 150ms, background-color 300ms;
  k: 1 / -1;
}

A few things to remember: list.nth() counts from 1, and -1 is the last element. list.append() and list.join() keep the first list's separator by default; pass $separator if you want something else. A single value is a list too (its separator is space); for a comma list with one element the trailing comma is required: (16px,). list.zip() pairs two lists and produces comma-separated shorthands such as transition. list.slash() builds slash values like grid-column: 1 / -1 without the risk of being read as division; this is the list side of / becoming a separator in 2.0.

Index mistakes are caught at compile time:

Error: $n: List index may not be 0.
  ╷
3 │ .x { margin: list.nth($space-scale, 0); }
  │              ^^^^^^^^^^^^^^^^^^^^^^^^^
Error: $n: Invalid index 5 for a list with 4 elements.

In Bookstore: rem(), space() and font-stack()

In bookstore-web I put the three functions into src/styles/abstracts/_units.scss and forwarded them from abstracts/_index.scss. The file contains only variables and functions, so the rule that abstracts produce no CSS still holds (npx sass --load-path=src/styles src/styles/abstracts/_index.scss outputs 0 bytes).

@use "sass:list";
@use "sass:math";

$root-font-size: 16px;

// spacing scale: space(0) … space(5)
$space-scale: 0 4px 8px 16px 24px 32px;

$font-system: system-ui, "Segoe UI", Roboto, sans-serif;

@function rem($value) {
    @if math.is-unitless($value) {
        $value: $value * 1px;
    }
    @if not math.compatible($value, 1px) {
        @error "rem() expects px or a unitless number, got #{$value}";
    }
    @return math.div($value, $root-font-size) * 1rem;
}

// steps start at 0, Sass lists at 1
@function space($step) {
    $last: list.length($space-scale) - 1;
    @if not math.is-unitless($step) or $step < 0 or $step > $last {
        @error "space() expects a step from 0 to #{$last}, got #{$step}";
    }
    @return list.nth($space-scale, $step + 1);
}

// the given fonts first, then the system stack, always comma-separated
@function font-stack($fonts...) {
    @return list.join($fonts, $font-system, $separator: comma);
}

space() exists because of the off-by-one mistake: the design document starts the scale at 0, the Sass list at 1. Doing the conversion in one place means nobody has to think about whether list.nth($space-scale, 2) is 4px or 8px. font-stack() takes a rest argument ($fonts...); called without arguments it returns only the system fonts. Usage:

// base/_typography.scss
h1,
h2,
h3 {
    font-family: font-stack("Iowan Old Style", Georgia);
    line-height: 1.2;
}

// app/book-card/book-card.scss
.book-card__title {
    margin: 0 0 space(1);
    font-size: rem(18px);

    @include from("lg") {
        font-size: rem(20px);
    }
}

After the build I measured the book titles at 400px and 1280px wide:

node scripts/web-probe.mjs ".book-card__title" font-size,font-family,margin-bottom light 400
Dune                     font-size=18px font-family="Iowan Old Style", Georgia, system-ui, "Segoe UI", Roboto, sans-serif margin-bottom=4px
...
Dune                     font-size=20px

When I wrote space(6) in a component by mistake, ng build stopped with our own message:

✘ [ERROR] Error: "space() expects a step from 0 to 5, got 6"
   ╷
18 │         margin: 0 0 space(6);
   │                     ^^^^^^^^
   ╵
  ../../../../src/app/book-card/book-card.scss 18:21  root stylesheet [plugin angular-sass]

The global style sheet went from 10.65 kB to 10.74 kB; the difference is the new font list on headings. npm run check:sass2 still says 3 files, 0 failed.

In Production

Remember that $root-font-size is an assumption. rem() takes 16px as the root size, which is the browser default. If someone adds a rule like html { font-size: 62.5%; }, every rem value shrinks, and Sass does not know about it. Using rem() instead of changing the root font size is safer for this reason: the user's browser setting is kept.

Keep an eye on decimals. Sass keeps 10 digits; 33.3333333333% is harmless, but hundreds of long numbers from a loop that generates a scale make the compiled CSS hard to read. Round inside the function, in one place.

Keep the build deterministic. Functions like string.unique-id() that produce different output on every compile break content-hashed file names and the CDN cache. The same source should always produce the same CSS; checking that in CI with a diff of two compiles is easy.

Common Pitfalls

Multiplying two numbers with units. The $base * 2px at the start of the post used to be a compile error; since 1.96.0 it is a calc(32px * 1px) the browser drops. In multiplication one side should always be unitless; use * 1px to add a unit and math.div($value, 1px) to remove one.

Counting list indexes from 0. Writing list.nth($list, 0) out of JavaScript and C# habit is a compile error; worse, list.nth($space-scale, 2) returns the second element, 4px, without any error. A wrapper like space() resolves that confusion in one place.

Keeping the global names. nth() and percentage() still work, but every use is a warning:

DEPRECATION WARNING [global-builtin]: Global built-in functions are deprecated and will be removed in Dart Sass 3.0.0.
Use list.nth instead.

More info and automated migrator: https://sass-lang.com/d/import

  ╷
4 │     padding: nth($space-scale, 3);
  │              ^^^^^^^^^^^^^^^^^^^^

These stay warnings in Dart Sass 2.0 but will be removed in 3.0. sass-migrator module turns them into list.nth() and math.percentage(); in new code use the module names from the start.

Forgetting to escape. A class name like .w-1/2 is a compile error, which is good; but if a name generated with interpolation lacks an escape and the character means something else in a selector (such as . or :), compilation succeeds and the rule targets the wrong elements.

A Small Experiment

★☆☆ In 08/type-scale.scss, change the ratio to 1.2 and add 4 to $steps. Check which two steps round to the same value when you pass $digits: 2 to round-to().

★★☆ Add a second parameter to rem(): $unit: rem. When given em it should return the result in em, and for any other value it should raise @error. Verify by compiling that rem(12px, em) produces 0.75em.

★★★ In bookstore-web, write a mixin that generates custom properties from $space-scale: --space-0 … --space-5 under :root. Then add a $var: true parameter to space() that returns var(--space-2). Write down when returning a compile-time value is the right choice and when a custom property is.

In the next post we move to the second half of the built-in modules: nested token maps and deep-merge with sass:map, color-space-aware color derivation with sass:color, and a theme for Bookstore's institutional portal.