Learn SCSS variables by compiling them. Ten copy-and-run examples covering types, arithmetic, derived colours, scope, compile-time resolution, !default, maps, functions, and CSS custom properties.
The SCSS variables article covers the most fundamental tool in the language: store a value once under a meaningful name, reference the name everywhere, and change it in a single edit. It also flags the concept that catches people out, which is that SCSS variables are resolved when the stylesheet compiles rather than while the page runs. That distinction sounds academic until you try to reassign one inside a media query and the result is not what you expected, which is why everything here compiles and the output is the argument. This workbook works through the whole range: your first variable, the types a variable can hold, arithmetic and a spacing scale, colours derived from one base, scope, compile-time order, !default and configuring a loaded partial, maps that hold a whole scale, variables as mixin and function parameters, and the line between a Sass variable and a CSS custom property. The idea that ties it together arrives in the last example, and it explains why both kinds of variable exist. Install the compiler once with npm install --save-dev sass and compile with npx sass main.scss main.css.
1. Your first variable
A variable is a dollar sign, a name, a colon, and a value. Use the name anywhere the value would have gone.
// main.scss$brand: #1e2331;$font-stack: "Segoe UI", sans-serif;$space: 16px;body { font-family: $font-stack; color: $brand; margin: $space;}.card { border: 1px solid $brand; padding: $space;}
/* main.css */body { font-family: "Segoe UI", sans-serif; color: #1e2331; margin: 16px;}.card { border: 1px solid #1e2331; padding: 16px;}
Five references, three declarations, and no $ anywhere in the compiled file. That absence is the first thing to internalise: the variable is a note to the compiler, substituted before the browser ever sees the stylesheet, so $brand costs nothing at runtime and cannot be read or changed by JavaScript. The names are doing the other half of the work, because $spaceexplains what 16 pixels is for in a way the number cannot, and a reader scanning the card rule learns that the padding and the body margin are deliberately the same value rather than coincidentally equal. Change #1e2331 on line one and both uses follow, which is the entire pitch.
2. What a variable can hold
Sass variables are not limited to colours and lengths. Knowing the types matters because several of them behave in ways a string would not.
// main.scss@use "sass:meta";$brand: #1e2331; // colour$stack: "Segoe UI", sans-serif; // list$space: 16px; // number with a unit$ratio: 1.5; // unitless number$title: "Quarterly report"; // string$compact: false; // boolean$shadow: null; // null$palette: (ink: #1e2331, muted: #6f7580); // map.types { brand: meta.type-of($brand); stack: meta.type-of($stack); space: meta.type-of($space); ratio: meta.type-of($ratio); title: meta.type-of($title); compact: meta.type-of($compact); shadow: meta.type-of($shadow); palette: meta.type-of($palette);}.applied { box-shadow: $shadow; /* null: watch this declaration disappear */ content: $title;}
/* main.css */.types { brand: color; stack: list; space: number; ratio: number; title: string; compact: bool; shadow: null; palette: map;}.applied { content: "Quarterly report";}
Eight types named by the compiler, and one declaration missing from the second rule. That disappearance is the useful surprise: a property whose value is null is dropped entirely rather than written out as the word null, which makes null the idiomatic way to say “no value here” in a mixin argument or a map. Booleans matter because they drive @if branches, and the distinction between 16px and 1.5 matters because Sass tracks units through arithmetic and will refuse combinations that make no sense. The map on the last line is a whole data structure in one variable, which Example 8 puts to work.
3. Arithmetic and a spacing scale
Numbers with units can be combined, which turns one base value into a coherent scale instead of a list of unrelated magic numbers.
// main.scss$space: 8px;.tight { padding: $space; }.normal { padding: $space * 2; }.loose { padding: $space * 4; }.mixed { margin: $space * 2 $space * 3; }.calcy { width: calc(100% - $space * 2); }
/* main.css */.tight { padding: 8px;}.normal { padding: 16px;}.loose { padding: 32px;}.mixed { margin: 16px 24px;}.calcy { width: calc(100% - 16px);}
Four spacing steps and a calc expression, all derived from one number. The arithmetic happened at compile time, so the browser receives plain lengths and there is no performance cost to expressing a scale this way. The interesting line is the last one, where #{...} is doing something specific: calc() is a CSS function the compiler leaves alone, so a bare $space * 2inside it would be passed through as literal text, and interpolation is what forces Sass to evaluate the expression first and drop the result in. Doubling the base changes every step in proportion, which is the property that makes a scale worth having, and it is also the reason to keep the base unitless-adjacent and simple rather than picking eight independent pixel values.
4. Deriving colours from one base
A hover state hardcoded as a second hex is a value that will drift. Derive it from the base and the two can never disagree.
// main.scss@use "sass:color";$button-bg: #3f88c5;.button { background: $button-bg; padding: 12px 20px; &:hover { background: color.scale($button-bg, $lightness: -20%); } &:active { background: color.scale($button-bg, $lightness: -35%); } &:disabled { background: color.scale($button-bg, $saturation: -60%); }}
/* main.css */.button { background: #3f88c5; padding: 12px 20px;}.button:hover { background: rgb(18.9239215686%, 42.7419607843%, 62.6447058824%);}.button:active { background: rgb(15.3756862745%, 34.7278431373%, 50.8988235294%);}.button:disabled { background: rgb(40.4705882353%, 51.9215686275%, 61.4901960784%);}
Four states from one hex code, and changing that hex moves all four together. Two notes on the functions before you copy this. The darken() and lighten() you will see in older tutorials are deprecated, and color.scale is the better replacement anyway because it adjusts proportionally toward the limit rather than subtracting a flat amount, so it never clips awkwardly on colours that are already dark. And modern Dart Sass emits colours as percentage rgb() rather than hex, which is valid CSS and startling the first time it shows up in a diff. The & nesting is what keeps all four states beside the base declaration, so nobody has to hunt for the hover rule to understand the component.
5. Scope
A variable declared inside a block belongs to that block. This is usually what you want and occasionally the reason a value is not what you expect.
// main.scss$brand: #1e2331; // global.card { $brand: #a94442; // local: shadows the global inside this block only border-color: $brand;}.panel { border-color: $brand; // the global was never touched}.sidebar { $pad: 12px !global; // force the declaration into the global scope padding: $pad;}.footer { padding: $pad; // visible, because of !global}
/* main.css */.card { border-color: #a94442;}.panel { border-color: #1e2331;}.sidebar { padding: 12px;}.footer { padding: 12px;}
The card is red, the panel is still navy, and the footer picked up a value declared inside a completely different rule. Shadowing is the normal case and it is safe, since a local $brand cannot leak out and surprise anyone. !global is the escape hatch and it is worth knowing mainly so you can recognise it in someone else’s stylesheet, because using it to declare a brand new variable is deprecated and Dart Sass will tell you so, recommending you add $pad: null at the root first. Declaring at the root and assigning where needed is clearer regardless, since a value that appears from inside an unrelated rule is exactly the kind of thing that makes a stylesheet hard to reason about.
6. Compile time, not run time
This is the concept that catches everyone. A variable reassigned inside a media query behaves like code being read top to bottom, not like a value the browser tracks.
// main.scss$size: 16px;@media (min-width: 768px) { .too-early { font-size: $size; } // read BEFORE the reassignment $size: 18px; .in-time { font-size: $size; } // read after}.outside { font-size: $size; } // did the change escape the block?
/* main.css */@media (min-width: 768px) { .too-early { font-size: 16px; } .in-time { font-size: 18px; }}.outside { font-size: 16px;}
Three answers, and two of them catch people out. The first rule got 16px because the compiler had not reached the reassignment yet, which is the trap the article warns about: order inside the file decides the value, and nothing about being inside a media query changes that. The third rule also got 16px, because a media query block is its own scope, so the reassignment never escaped it. Put those together and the model is clear: at 800 pixels wide a browser applies the media query and .in-time renders at 18px, but that is because two different literal values were compiled into two different rules, not because anything was recalculated. The variable was gone before the file was served, and if you want a value the browser can genuinely change at runtime, Example 10 has the right tool.
7. !default and configuring a partial
A variable declared with !default takes its value only if it does not already have one, which is what lets a shared partial ship sensible values a project can override.
// _theme.scss$brand: #1e2331 !default;$radius: 6px !default;
// main.scss@use 'theme' with ($brand: #3f88c5);.btn { background: theme.$brand; border-radius: theme.$radius;}
/* main.css */.btn { background: #3f88c5; border-radius: 6px;}
The brand colour was overridden and the radius kept its default, with the partial itself untouched. with () is the modern form and it is stricter and clearer than the old approach of assigning variables above an @import: the configuration sits on the load line where you can see it, it fails loudly if you try to configure a variable that is not marked !default, and it can only happen once, so no later file can quietly reconfigure the module behind your back. This is exactly how you retheme a framework without forking it. Two habits go with it: put every value you intend to be configurable behind !default, and keep them in a dedicated _variables.scss or _theme.scss grouped into sections, so colours sit with colours and spacing with spacing.
8. Maps: a whole scale in one variable
When values belong together, a map holds them as one structure you can look values up in and loop over.
// main.scss@use "sass:map";$palette: ( ink: #1e2331, muted: #6f7580, surface: #ffffff, line: #e2e6ef,);$space: (xs: 4px, sm: 8px, md: 16px, lg: 24px);.card { background: map.get($palette, surface); border: 1px solid map.get($palette, line); color: map.get($palette, ink); padding: map.get($space, lg);}// A map is iterable, so one loop emits the whole scale@each $name, $value in $space { .$name { padding: $value; }}
/* main.css */.card { background: #ffffff; border: 1px solid #e2e6ef; color: #1e2331; padding: 24px;}.pad-xs { padding: 4px;}.pad-sm { padding: 8px;}.pad-md { padding: 16px;}.pad-lg { padding: 24px;}
A component styled from named lookups, and four utility classes generated from a loop nobody had to write four times. Two things a map buys you over four separate variables. Adding a fifth spacing step means editing one line and every generated class appears automatically, which is how design tokens stay in sync with the CSS. And the names become an interface, so map.get($palette, surface) reads as intent while $colour-4 reads as nothing. Use map.get rather than the older global map-get, which still works but is deprecated, and remember that a missing key returns null rather than an error, so a typo will silently drop the declaration as Example 2 showed.
9. Variables as mixin and function parameters
Variables stop being storage and start being logic when they travel into mixins and functions as arguments.
// main.scss@use "sass:map";$space-unit: 8px;$space: (xs: 0.5, sm: 1, md: 2, lg: 3);// A function returns one value built from the variables@function space($name) { @return map.get($space, $name) * $space-unit;}// A mixin takes a variable as a parameter, with a default@mixin inset($size: md) { padding: space($size);}.card { @include inset; }.compact { @include inset(sm); }.hero { @include inset(lg); margin-bottom: space(md); }
/* main.css */.card { padding: 16px;}.compact { padding: 8px;}.hero { padding: 24px; margin-bottom: 16px;}
Three components, three spacing decisions, and not a single pixel value written in the component rules. The chain is worth tracing: the map stores multipliers, the function turns a name into a length, the mixin wraps that in a declaration with a sensible default, and the components ask for sm or lg and get numbers that are guaranteed to be on the scale. Changing $space-unit from 8px to 10px rescales the entire design in one edit. This is also where the article’s warning about restraint applies, because a value used once in one rule gains nothing from this treatment; the machinery earns its place when values are reused, carry meaning, or are likely to change, and pays for itself in nothing at all when they are not.
10. Sass variables versus CSS custom properties
Both are called variables and they live on opposite sides of the compiler. Putting them in the same file makes the difference concrete.
// main.scss$brand: #1e2331;:root { --brand: $brand; /* interpolation required */ --brand-raw: $brand; /* without it, the text passes through as-is */ --space: 16px;}.card { border-color: $brand; /* compiled away */ background: var(--brand); /* survives to the browser */ padding: var(--space);}.theme-dark { --brand: #f7f9fc; /* runtime override, no recompile */}
/* main.css */:root { --brand: #1e2331; --brand-raw: $brand; --space: 16px;}.card { border-color: #1e2331; background: var(--brand); padding: var(--space);}.theme-dark { --brand: #f7f9fc;}
Look at the second line of :root. The literal text $brand shipped to the browser, because custom property values are not Sass expressions and are passed through untouched, so interpolation with #{} is mandatory rather than stylistic. Everything else in the output tells the same story from a different angle: $brand vanished into a hex code, while --brand is still there, still named, and still overridable by a class further down. Adding theme-dark to an element reskins the card with no recompile and no page reload, which a Sass variable can never do.
The model behind both is a single question about when the value is decided. A Sass variable is decided at compile time, so it can do arithmetic, hold maps and booleans, feed @if and @each, and configure a partial through !default, but it is gone before the browser starts and nothing at runtime can see it. A custom property is decided at run time, so it inherits down the tree, responds to a class change or a media query live, and can be read and written by JavaScript, but it cannot do maths in a map or drive a loop. Every example above sits on one side of that line: types, arithmetic, derived colours, scope, !default, maps and function arguments are all compile-time powers, and the media query in Example 6 only looked like a runtime one. The practical division most projects settle on is to build the design system in Sass variables, where the tooling is, and expose the handful of values that need to change while the page is running as custom properties, which is theming, user preferences, and anything JavaScript touches.
Work through these and you have the article in practice: a variable declared and reused; the eight types and the null that deletes a declaration; arithmetic that turns one number into a scale, with interpolation inside calc; hover and disabled states derived from a single colour; local scope, shadowing, and the deprecated !global; compile-time resolution proved by a media query that answers in two surprising ways; !default with @use ... with for configuring a partial you do not own; maps holding a palette and a scale you can loop over; variables as mixin and function arguments; and the compile-time versus run-time line that decides between $brand and --brand. The habit that follows is a naming one: before creating a variable, say out loud what the value means rather than what it currently is, because $brand survives a redesign and $dark-bluebecomes a lie the first time the brand changes.
Thanks for reading, Andrei.
[…] Storing Values Once with SCSS Variables: 10 Code-Along Examples […]
[…] Storing Values Once with SCSS Variables: 10 Code-Along Examples […]