CSS Custom Properties (vars) with SASS/SCSS, a practical architecture strategy

← ← ←   5/2/2022, 4:55:20 PM | Posted by: Felippe Regazio


If you dont know about CSS Custom Properties yet, you really should learn about. I particularly prefer to use CSS custom properties instead of SASS variables always when possible, mostly because of its reactivity. For this post I'll assume that you're already comfortable with CSS Custom Properties and SASS Vars (with SCSS Syntax).

Sometimes the use of CSS Custom Properties is not a case of taste, but a necessity. It just fits perfectly to build themes, dynamic components, configurable pages etc. But as our page grows, also grows the maintenance complexity of the code base and the risk of create a modules labyrinth.

A common approach when dealing with SASS/SCSS variables is to create a _vars file which will hold most of our app variables, and in the case of CSS Custom Properties, also add a prefix to avoid conflicts. The idea here is the use of CSS Custom Props, but i think it fits to SASS vars as well. Here is a common pattern:

  
  $prefix: pf;

  :root {
    --#{$prefix}-primary-color: #000000; 
    // this will compile to: --pf-primary-color: #000000;
  }    
  

Now we use it along the stylesheets:

  
  @import './config/_vars';

  .btn-example {
    background-color: var(--#{$prefix}-primary-color);
  }    
  

Now instead of doing and maintain this:

  
  :root {
    --#{$prefix}-base-font-size: 65.5%;
    --#{$prefix}-font-family: "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
  
    --#{$prefix}-primary-color: #33b5e5;
    --#{$prefix}-secondary-color: #ff500a;
  }    
  

You will do and maintain this:

  
  @include cssvars((
    base-font-size: 65.5%,
    font-family: #{"HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif},
  
    primary-color: #33b5e5,
    secondary-color: #ff500a,
  ));   
  

The result will be the same:

  
  :root {
    --pf-base-font-size: 65.5%;
    --pf-font-family: "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
    --pf-primary-color: #33b5e5;
    --pf-secondary-color: #ff500a;
  }    
  

You can also override the prefix passing another one as the second argument. Note that for all the functions and mixins showed here, the $prefix will appear as param with a default value. I prefer to use it that way, but you can put it out and use a single $prefix var if you prefer.

Maybe you can add a new argument to override the ":root" selector creating scoped custom properties declarations, i dont know... You can tweak this mixin to fit your style and needings, the idea here is to have a setter for your Custom Properties that will allow you to have a pattern and single source of truth when dealing with your variables, leading to a solid application growth.

Getting a Custom property

Lets create a getter to retrieve our CSS Custom Properties avoiding weird syntax and dense code for simple things:

  
  /**
    * Retrieve a css variable value with prefix
    *
    * Usage
    *
    * .selector {
    *   color: cssva(primary-color);
    * }
    *
    * Will result in
    *
    * .selector {
    *    color: var(--prefix-primary-color);
    * }
    */
   @function cssvar($name, $prefix: pm) {
       @return var(--#{$prefix}-#{$name});
   }
  

Now, instead of this:

  
  :root {
    --#{$prefix}-button-height: 40px;
    --#{$prefix}-button-color: #ffffff;
    --#{$prefix}-button-background: #000000;
  
    .button-primary {
      height: var(--#{$prefix}-button-height);
      line-height: var(--#{$prefix}-button-height);
      color: var(--#{$prefix}-button-color);
      background-color: var(--#{$prefix}-button-background);
    }
  }    
  

We get this:

  
  @include cssvars((
    button-height: 40px,
    button-color: #ffffff,
    button-background: #000000,
  ));
  
  .btn-primary {
    height: cssvar(button-height);
    line-height: cssvar(button-height);
    color: cssvar(button-color);
    background-color: cssvar(button-background);
  }    
  

Which will result in this:

  
  :root {
    --pm-button-height: 40px;
    --pm-button-color: #ffffff;
    --pm-button-background: #000000;
  }
  
  .btn-primary {
    height: var(--pm-button-height);
    line-height: var(--pm-button-height);
    color: var(--pm-button-color);
    background-color: var(--pm-button-height);
  }    
  

The syntax, readability and maintainment where improved. Also be aware that when we talk about prefix here, we are talking about prefixes for variable names like --prefix-color:blue, not css native properties like -moz-*.

Updating a Custom Property

Lets say we want to add a big button now. Simple, we just override the value of our button-height var to a bigger one in a class. So, we will need a mixin that updates (overrides) a custom property value. To do it in a clean way, add this mixin:

  
  @mixin cssvar ($name, $value: '', $prefix: pm) {
    --#{$prefix}-#{$name}: #{$value};
  }  
  

Now, instead of this:

  
  @include cssvars((...));

  .btn-primary {
    height: cssvar(button-height);
    line-height: cssvar(button-height);
    color: cssvar(button-color);
    background-color: cssvar(button-background);
    &--big {
      // ** LOOK HERE **
      --#{$prefix}-button-height: 56px;
    }
  }    
  

We get this:

  
  @include cssvars((...));

  .btn-primary {
    height: cssvar(button-height);
    line-height: cssvar(button-height);
    color: cssvar(button-color);
    background-color: cssvar(button-background);
    &--big {
      // ** LOOK HERE **
      @include cssvar(button-height, 56px);
    }
  }    
  

With the following result:

  
  :root { ...vars }

  .btn-primary {
    height: var(--pm-button-height);
    line-height: var(--pm-button-height);
    color: var(--pm-button-color);
    background-color: var(--pm-button-height);
  }
  
  .btn-primary--big {
    --pm-button-height: 56px;
  }    
  

Now we have a powerful toolset :)

An organization proposal

Now that we have a cool set of helpers, lets use it! Here is my tip:

  1. Create a _helpers file with the function and mixins showed here, allowing you to deal with the CSS Custom Properties easily.
  2. Create a _config file to hold you application global custom properties, the ones that concerns to the entire application, like primary and secondary colors, font-family, base-font-size, container-width, etc.
  3. Now Create single stylesheets by concern, and keep them as autonomous universes. For example, to buttons you must create a _buttons.scss that holds all the variables, selectors, properties, styles, etc. If you have a large set of buttons, put the files in a "buttons" folder and split the variations in new files joining them in a main file on the buttons folder. Same for Forms, Typography, etc etc.
  4. Call your _helpers, _config, and all the application modules (buttons, forms, typo...) in a main file. You can also create a core.css that holds only the _helpers and _config, then compile the modules stylesheets separately, then you add the core and the modules you want.

At the end you will have a folder/file tree almost like that:

  
  styles
    config
        _helpers.scss
        _config.scss
    modules
        _buttons.scss
        _form.scss
        _typography.scss
        _tables.scss
    main.scss 
  

At runtime, as we are talking about CSS Custom Properties, once declared in the :root {}, all variables will be globally available in its context. You can use them anywhere without myriads of @imports. They are just available. Of course this is sometimes a bad thing depending on your codebase size and can harm your performance - or if you dont need dynamic vars you can use SASS vars, but keeping them in its right place. What i mean is that you can easily tweak this architecture to scope your custom props.

Our main.scss would be something almost like that:

  
  @import './config/_helpers';
  @import './config/_config';
  
  @import './modules/_typography';
  @import './modules/_buttons';
  @import './modules/_form';
  @import './modules/_tables';
  
  // etc    
  

And this would be a module:

  
  @include cssvars((
      btn-primary-text-color: #ffffff,
      btn-secondary-text-color: #ffffff,
      btn-border-radius: 4px,
      btn-text-transform: uppercase,
      btn-font-size: 12px,
      btn-font-weight: 600,
      btn-height: 40px,
      btn-padding: 0 30px,
  ));
  
  .button, button,
  input[type="button"],
  input[type="submit"],
  input[type="reset"] {
          // { hard coded properties }
      height: cssvar(btn-height);
      padding: cssvar(btn-padding);
      line-height: cssvar(btn-height);
      font-size: cssvar(btn-font-size);
      font-weight: cssvar(btn-font-weight);
      letter-spacing: cssvar(btn-font-weight);
      color: cssvar(primary-color);
      border-radius: cssvar(btn-border-radius);
      border: 1px solid cssvar(primary-color);
      text-transform: cssvar(btn-text-transform);
      &.focus, &:focus,
      &.hover, &:hover {
          // { ... whatever you want }
      }
      &.button-block {
          display: block;
          width: 100%;
      }
      &.button-primary {
          color: cssvar(btn-primary-text-color);
          background-color: cssvar(primary-color);
          border-color: cssvar(primary-color);
      }
      &.button-secondary {
          color: cssvar(btn-secondary-text-color);
          background-color: cssvar(secondary-color);
          border-color: cssvar(secondary-color);
      }
      &.button--big {
          @include cssvar(btn-height, 56px);
      }
  }  
  

This is just an example. A button can be far more complex. But note that all the things the button needs is in itself. And when imported, its Custom Properties will be globally available on the context. Also look that we are using our helpers to provide a pattern and reliability.

How that solves the first 6 Problems

At the beginning of this post i talked about 6 common problems in SCSS code bases. Here is how that approach will solve them:

Now that we have a nice and clean syntax to deal with prefixed props there is no need to copy paste codes to gain speed, its easy and fast to use our helpers, change and maintain our css props is really fun and they are also prefixed and standardized.

Its easy and safe to onboard new devs on the code base. When talking about vars we basically have 3 functions to deal with variables and to explain to them. The _config file holds the app globals and the another vars are on the module itself:

  
  // setter
  @include cssvars((...));
  
  // updater
  @include cssvar(name, value);
  
  // getter fn
  prop: cssvar(name);    
  

There is a real separation of concerns. We keep any module as a single universe, declaring everything there and only providing the output. If we need to split a module, it will become a folder with a main exporter file, no need to keep fishing files on the project trying to discover where are all the peaces.

Conclusion

The function and mixins showed here are a demonstration of how to provide an architecture to a solid growth. But there is no silver bullet, you can of course change it to fit your needings, change names, arguments, its just a spark of an idea, you can adapt as you need, the idea is help application to expand without pain :)