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

Sass Basics: Variables, Nesting, the Parent Selector (&), Interpolation and Placeholders

Mert Özen Nov 14, 2026 11 min 11 views
Sass Basics: Variables, Nesting, the Parent Selector (&), Interpolation and Placeholders

A Sass variable is a build-time value that disappears after compilation, while a custom property lives at runtime, so when combining them you must pass the value through #{}, or the browser receives the text "$brand". We rewrite Bookstore's card with & and BEM, measure in two engines how Sass nesting produces different specificity from native nesting, and show how deep nesting bloats the output.

Version note: The examples in this post were run with Dart Sass 1.108.2, Angular 22.2 (TypeScript 6.0) and the Chromium and WebKit builds of Playwright 1.65.

Guess First

For Bookstore, the online bookstore used as the shared example across this site's series, we keep the colors in Sass variables but want the browser to see them too. The first thing that comes to mind:

$brand: #b45309;

:root {
    --color-brand-raw: $brand;
}

What is the value of --color-brand-raw in the compiled CSS? #b45309, or something else? The answer shows where Sass draws the line between variables and custom properties; the first section of this post is about that line.

In the first post of the series we saw that Sass is a build step and which gaps native CSS has closed. This post covers the basic syntax you use every day: variables, nesting and &, interpolation, placeholders and comments. We will rewrite the card style of Bookstore's Angular client (bookstore-web) with BEM, and finish by measuring what too much nesting turns into in the compiled CSS.

$variable and Custom Properties: Build Time and Runtime

The answer comes from the compiler:

$brand: #b45309;
$radius: 8px;
$gutter: 16px;

:root {
    // without #{} the value is copied as written
    --color-brand-raw: $brand;
    --color-brand: #{$brand};
    --radius: #{$radius};
}

.book-list {
    // Sass knows calc(): no interpolation needed
    width: calc(100% - $gutter * 2);
    padding-inline: $gutter;
}
npx sass 02/tokens.scss
:root {
  --color-brand-raw: $brand;
  --color-brand: #b45309;
  --radius: 8px;
}

.book-list {
  width: calc(100% - 32px);
  padding-inline: 16px;
}

The value of --color-brand-raw reaches the browser as the text $brand. The reason is how CSS defines custom properties: their value can be almost anything, even a piece of JavaScript. So Sass leaves custom property values alone, and if you want a Sass expression evaluated there you have to say so explicitly with #{}. The browser raises no error; the declaration that uses var(--color-brand-raw) just silently does not work.

The second rule shows the opposite direction. Inside calc() you can use a Sass variable directly; Dart Sass understands calc(), does as much of the arithmetic as it can at build time ($gutter * 2 became 32px) and leaves the part it cannot know at build time, like 100%, to the browser. The calc(100% - #{$gutter}) form you see in older code still works but is no longer needed.

The difference between the two kinds of variables comes down to this: $brand is a build-time value that disappears once compilation finishes; --color-brand is a runtime property that takes part in the cascade, is inherited, can change per media query or class, and can be read and written from JavaScript. When to use which was covered in detail in the custom properties post of the CSS3 series. Bookstore's rule is: design values are defined in Sass, components use only custom properties. The global styles.scss:

// design values live in Sass; the browser gets custom properties
$color-brand: #b45309;
$color-border: #d6d3d1;
$color-text: #1c1917;
$color-muted: #57534e;
$radius: 8px;
$space: 16px;

:root {
    --color-brand: #{$color-brand};
    --color-border: #{$color-border};
    --color-text: #{$color-text};
    --color-muted: #{$color-muted};
    --radius: #{$radius};
    --space: #{$space};
}

body {
    margin: 0;
    font-family: system-ui, sans-serif;
    color: var(--color-text);
}

The global stylesheet produced by ng build:

:root{--color-brand: #b45309;--color-border: #d6d3d1;--color-text: #1c1917;--color-muted: #57534e;--radius: 8px;--space: 16px}body{margin:0;font-family:system-ui,sans-serif;color:var(--color-text)}

This split has a practical benefit: component files do not see global Sass variables (every .scss file is compiled separately; the way to share variables is the module system in the fourth post of the series), but custom properties reach every component through the DOM. Switching themes also means changing only the custom properties; we will do that in the fifth post.

Sass variables also have scope. A variable declared inside a block is visible only in that block; one declared at the top level is visible across the file. There is a !global flag for changing a global variable from inside a block, but everywhere I have used it, it caused confusion later; producing the value as the result of a function is almost always more readable.

Nesting and &: the Book Card with BEM

The most used part of Sass nesting is &. In Sass, & is the text of the parent selector; you can glue more text before or after it. The lab version of Bookstore's card:

.book-card {
    padding: var(--space, 16px);
    border: 1px solid #d6d3d1;

    // BEM element: .book-card__title
    &__title {
        margin: 0;
        font-size: 1.125rem;
    }

    // BEM modifier: .book-card--featured
    &--featured {
        border-color: var(--color-brand);

        // inside a modifier, & is .book-card--featured
        .book-card__title {
            color: var(--color-brand);
        }
    }

    &:hover,
    &:focus-within {
        border-color: #a8a29e;
    }

    // & at the end: the context comes first
    .catalog--compact & {
        padding: 8px;
    }

    // sibling cards
    & + & {
        margin-top: 0;
    }
}
.book-card {
  padding: var(--space, 16px);
  border: 1px solid #d6d3d1;
}
.book-card__title {
  margin: 0;
  font-size: 1.125rem;
}
.book-card--featured {
  border-color: var(--color-brand);
}
.book-card--featured .book-card__title {
  color: var(--color-brand);
}
.book-card:hover, .book-card:focus-within {
  border-color: #a8a29e;
}
.catalog--compact .book-card {
  padding: 8px;
}
.book-card + .book-card {
  margin-top: 0;
}

Three things stand out in the output. Although &__title and &--featured are nested in the source, they come out as flat single-class selectors: that is the whole point of BEM, every element has a specificity of (0,1,0). .catalog--compact & puts & at the end to say "when this card is inside that context"; Sass turns it into .catalog--compact .book-card. And a selector list (&:hover, &:focus-within) is expanded part by part.

Nesting an element inside a modifier (&--featured .book-card__title) is a deliberate exception in BEM: the modifier describes how a state affects the whole card, and keeping that in one place makes it easier to read. The price is that the rule's specificity is (0,2,0).

Bookstore's BookCard component now uses this structure. I added the [class.book-card--featured]="book().featured" binding to the template and made Dune the featured book. The component's stylesheet:

.book-card {
    padding: var(--space);
    border: 1px solid var(--color-border);
    border-radius: var(--radius);

    &:hover {
        border-color: var(--color-brand);
    }

    &--featured {
        border-width: 2px;
        border-color: var(--color-brand);
    }

    &__title {
        margin: 0 0 4px;
        color: var(--color-brand);
    }

    &__author {
        margin: 0;
        color: var(--color-muted);
    }

    &__price {
        font-weight: 700;
    }
}

After the build I opened the page with a small Playwright script and read the card borders. The script serves the dist folder with a tiny built-in HTTP server and prints computed values for the given selector:

node scripts/web-probe.mjs ".book-card" border-top-color,border-top-width
Dune                     border-top-color=rgb(180, 83, 9) border-top-width=2px
Emma                     border-top-color=rgb(214, 211, 209) border-top-width=1px
The Hobbit               border-top-color=rgb(214, 211, 209) border-top-width=1px
Middlemarch              border-top-color=rgb(214, 211, 209) border-top-width=1px

The var(--color-brand) inside the component comes from the global :root; Angular's emulated encapsulation adds attributes to selectors but leaves custom properties alone.

Sass Nesting Is Not Native Nesting

Sass always flattens nesting: no nested rules remain in the output. That means you can get a different result if you give the same file to the browser without compiling it. In the first post we saw that &__title does not work in native CSS. A lesser-known difference shows up when the parent is a selector list:

#featured,
.book-card {
    .price {
        font-weight: 700;
    }
}

.book-card .price {
    font-weight: 400;
}

This file is both valid SCSS and valid native CSS. The script gives the same file to two engines, once as is and once after running it through Sass:

node scripts/02-list-parent.mjs
chromium native nesting    .book-card .price → font-weight 700
chromium compiled by Sass  .book-card .price → font-weight 400
webkit   native nesting    .book-card .price → font-weight 700
webkit   compiled by Sass  .book-card .price → font-weight 400

Sass expands the parent list into two separate selectors: #featured .price, .book-card .price. For the price inside the card the matching selector is .book-card .price with a specificity of (0,2,0); the rule below with the same specificity wins because it comes later. The browser, however, reads the parent list as :is(#featured, .book-card), and :is() takes the highest specificity in its list, the id's: (1,1,0). One way gives 400, the other 700. In a project moving from Sass to native nesting, lines like this change the look without any error; before the move, search the compiled CSS for selector lists. The details of :is() are in the nesting post of the CSS3 series.

Interpolation: Building Selectors, Properties and Strings with #{}

#{} inserts the result of a Sass expression into the CSS as text. It is useful anywhere a variable cannot normally go: selector names, property names, URLs and inside strings:

$block: "order-summary";
$edge: "inline-start";
$asset-base: "/assets/covers";

.#{$block} {
    border-#{$edge}: 4px solid #b45309;

    &__row {
        display: flex;
    }
}

.book-cover--placeholder {
    background-image: url("#{$asset-base}/placeholder.webp");
}

// works inside a quoted string too
.debug::after {
    content: "#{$block}";
}
.order-summary {
  border-inline-start: 4px solid #b45309;
}
.order-summary__row {
  display: flex;
}

.book-cover--placeholder {
  background-image: url("/assets/covers/placeholder.webp");
}

.debug::after {
  content: "order-summary";
}

The result of interpolation is always an unquoted string; although $block was declared with quotes, it comes out unquoted in the selector. That is why you should avoid interpolation when working with numbers: #{$space}px produces a string that looks like 16px but is no longer a number; try arithmetic on it and Sass fails with Undefined operation "16px * 2". To add a unit, write $space * 1px. We will use interpolation most when generating classes in loops (the third post of the series).

An Introduction to Placeholders and @extend

A selector starting with % is a placeholder: it produces no CSS on its own and only reaches the output when it is @extended. Bookstore needs the same rule in two places for text that screen readers announce but that takes up no space on screen:

%visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

// never extended: produces no CSS
%unused-card-shadow {
    box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
}

.skip-link:not(:focus) {
    @extend %visually-hidden;
}

.cart-icon__label {
    @extend %visually-hidden;
}
.cart-icon__label, .skip-link:not(:focus) {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@extend does not copy the declarations; it adds the extending selectors to the placeholder's rule. The output has one rule with two selectors, and %unused-card-shadow is not there at all. That differs from a mixin, which writes the declarations again on every @include. When to choose which, and why @extend does not work inside a media query, is the subject of the next post.

Comments and Their Effect on the Output

Sass has three kinds of comments, and they affect the output differently:

// silent comment: never in the output
/* loud comment: in expanded output, removed by compressed */
/*! preserved comment: survives compressed output too */
.book-card__price::before {
    content: "₺"; // trailing silent comment
}
npx sass 02/comments.scss
@charset "UTF-8";
/* loud comment: in expanded output, removed by compressed */
/*! preserved comment: survives compressed output too */
.book-card__price::before {
  content: "₺";
}

// comments never reach the output; that is the right place for notes within the team. /* */ comments stay in expanded output and are removed with --style=compressed. /*! */ survives compressed output too; it is used for license headers. Angular's production build minifies the CSS separately, so check the behavior in your own build there as well.

The first line of the output is unexpected: @charset "UTF-8";. When Sass sees a non-ASCII character in the output (here ₺; a /* */ comment written in Turkish has the same effect), it adds this line in expanded mode, and a UTF-8 BOM at the start of the file in compressed mode:

npx sass --style=compressed 02/comments.scss | head -c 3 | od -An -tx1
           ef  bb  bf

On its own this is harmless, but it causes trouble in a tool chain that pastes the CSS into the middle of another file or compares it byte by byte. The Sass CLI's --no-charset turns this behavior off.

In Production

Forgetting interpolation when generating custom properties from Sass variables does not break the build; it breaks the page. The cheapest way to catch it is to search the compiled CSS for the $ character: grep -n '\$' dist/**/*.css should come back empty. Since Sass does not treat this as an error, the check is worth adding to CI.

Do not mix Sass nesting and native nesting in the same code base. In Angular every .scss file goes through Sass, so native nesting comes out flattened anyway; nesting in plain .css files, on the other hand, is interpreted by the browser, not by Sass. The same rule working with :is() specificity in one file and flat-selector specificity in another makes debugging harder.

Read the compiled CSS. The way to see what Sass produced is the output of npx sass file.scss, or in Angular the stylesheet of a development build. Looking at the output at least once in code review, not just at the source, catches every surprise in this post.

Common Pitfalls

The most common pitfall is copying the HTML tree into Sass one to one. The order summary on the checkout page was written like this:

.checkout {
    .order-summary {
        .order-summary__items {
            li {
                display: flex;
                justify-content: space-between;

                .title {
                    font-weight: 600;

                    a {
                        color: inherit;

                        &:hover {
                            text-decoration: underline;
                        }
                    }
                }

                .price {
                    font-variant-numeric: tabular-nums;
                }
            }
        }
    }
}
.checkout .order-summary .order-summary__items li {
  display: flex;
  justify-content: space-between;
}
.checkout .order-summary .order-summary__items li .title {
  font-weight: 600;
}
.checkout .order-summary .order-summary__items li .title a {
  color: inherit;
}
.checkout .order-summary .order-summary__items li .title a:hover {
  text-decoration: underline;
}
.checkout .order-summary .order-summary__items li .price {
  font-variant-numeric: tabular-nums;
}

Indentation that looks harmless in the source adds a four-selector prefix to every rule in the output. The last rule's specificity is (0,5,2): four classes, one :hover, two elements. Writing the same styles flat with BEM (.order-summary__item, .order-summary__title, .order-summary__link, .order-summary__price) takes the compressed output from 422 bytes to 247 bytes. A small difference for one component, but once the habit spreads across hundreds of files the stylesheet grows noticeably.

The real cost is not size but overriding. Imagine someone later adds .is-muted { font-weight: 400; } to show a title muted:

node scripts/02-deep-override.mjs
02/deep.scss  .is-muted → font-weight 600
02/flat.scss  .is-muted → font-weight 400

In the deeply nested version a single-class rule (0,1,0) can never win; the developer either writes an equally long selector or reaches for !important, and the specificity race begins. My rule: at most three levels in the source and at most two classes in the output. Pseudo-classes (&:hover), modifiers (&--featured) and media queries are worth nesting; DOM structure is not.

The second pitfall is that building class names with & hurts searchability. Write &__title, and searching the project for book-card__title does not find that line. In Bookstore we allow it at the element and modifier level, but not for deeper concatenations (&__title &__icon).

A Small Experiment

★☆☆ In 02/tokens.scss, add a rule .price { color: var(--color-brand-raw, green); } that uses the --color-brand-raw: $brand; line and check in the browser which color the price gets. Explain why the fallback does not kick in.

★★☆ Rewrite 02/deep.scss with BEM, using at most two levels of nesting. Compare the compressed output size and the specificity of the longest selector before and after.

★★★ In bookstore-web, add a book-card--out-of-stock modifier to BookCard: when stock is zero, strike through the price and mute the title. Write the rule nested inside the modifier, then verify the computed values of The Hobbit's card with web-probe.mjs.

In the next post we move on to the tools that remove repetition: a breakpoint mixin with @mixin and @content, @function and sass:math, generating utility classes with maps and @each, the choice between mixins and @extend, and what making everything a mixin costs in CSS output.