CSS4 Variables with a Fallback

Almost a year ago, I wrote a CodePen post about CSS4 variables and Sass. Now that a handful of browsers support the CSS4 variable spec well, I figured it was worth revisiting the idea and came up with an approach that is not for everyone, but is more concise and includes a fallback for browsers that don’t support variables.

How CSS4 variables work

Variables have a -- prefix eg. --my-var. Referring to a defined variable looks like var(--my-var) In order to define CSS4 variables, they must be scoped to something. For global variables, you can scope to :root.

  :root {
  --color-1: #222;
}

Once you define your variables, you can use them for anything in that scope. Every element is in the :root scope. This means any element can use variables defined in :root scope.

  :root {
  --color-1: #222;
}

body {
  color: var(--color-1);
}

Goals

Since CSS4 variables aren’t supported by every browser, we only want to use them additively. Sass can help make that process easier. We are going to turn this:

  body {
  color: #222;
  color: var(--color-text);
  background-color: #FFFF;
  background-color: var(--color-background);
}

Into this:

  body {
  @include v(color, text);
  @include v(background-color, background);
}

Start with Sass variables maps

To start, we use Sass variable maps to define our variables.

  $color: (
  text: #222,
  background: #F0F0F0
);

As you can see, this is a really nice way to organize your variables. Maps are JSON-like key-value pairs. To access text from $color we can say map-get($color, text).

That isn’t all. We can also nest maps.

  $font: (
  size: (
    body: 18px,
    h1: 2.4em, h2: 2em,
    h3: 1.6em, h4: 1.4em,
  ),
  leading: (
    body: 1.6,
    head: 1.2,
  ),
  family: (
    body: (Merriweather, Georgia, serif),
    head: (Roboto, Helvetica, sans-serif),
    code: (Menlo, Andale Mono, monospace),
  ),
);

Note how the family values are wrapped in (). This is because the commas are a part of the actual value we want returned. If I removed the wrapping (), Sass would not be able to recognize that each family name wasn’t another $font.family property. By wrapping them in parens, Sass treats the families like a Sass list. When you return a Sass list, it removes the wrapping parens, which is what we want.

In order to access a nested map, you simply nest your map-gets.

  map-get(map-get($font, size), h1)

This is essentially the same as saying:

  $nested-font-size-map: map-get($font, size);
$nested-font-size-value: map-get($nested-font-size-map, h1);

CSS properties in Sass maps

In order to access these maps intelligently, we need to map property names like background-color with variable maps like $color. To do this, we can use a map.

To start, we map literal names to the variable $map equivalent. This comes into play later.

  // associate variable map name to an actual variable map
$var-maps: (
  color: $color, 
  font:  $font
);

The word color is now associated with the map $color. and font with $font. Now, we map every property we want to use CSS4 variables for with the word for the $map it is associated with.

  // which property uses which var map
// you would edit this for each property you want to use variables for
$props: (
  background-color: color,
  color:            color,
  font-family:      (font, family),
  font-size:        (font, size),
  font-weight:      (font, weight),
  line-height:      (font, leading),
);

Notice how the font properties are Sass lists. That is because our $font map has nested maps. The first value is the main $map name, the second is the nested-map name.

At this point our code looks like this:

  // all of our color variables
$color: (
  text: #222,
  background: #F0F0F0
);

// all of our font variables
$font: (
  size: (
    body: 18px,
    h1: 2.4em, h2: 2em,
    h3: 1.6em, h4: 1.4em,
  ),
  leading: (
    body: 1.6,
    head: 1.2,
  ),
  family: (
    body: (Merriweather, Georgia, serif),
    head: (Roboto, Helvetica, sans-serif),
    code: (Menlo, Andale Mono, monospace),
  ),
);

// associate variable map name to an actual variable map
$var-maps: (
  color: $color, 
  font:  $font
);

// which property uses which var map
// you would edit this for each property you want to use variables for
$props: (
  background-color: color,
  color:            color,
  font-family:      (font, family),
  font-size:        (font, size),
  font-weight:      (font, weight),
  line-height:      (font, leading),
);

At this point, all the code we have written is all the code you'll ever need to modify to keep this up to date. If you want new colors, you add them to the $color map. If you want to extend this approach to different types of variables, you would:

  • create a new variable map
  • add name mapping to $var-maps
  • add properties it is used for to $props

Voila! You are done.

Just kidding. We need to use this stuff now. The following code you will only need to write once, and never modify.

One @mixin to rule them

In order to have a fallback, we need to write a property twice. Essentially we want to be able to output the following code.

  body { 
  color: #222; 
  color: var(--color-text);
}

There's no way to do this programmatically without a mixin. We will have our @mixin take two main variables. $prop and $var. $prop is the property name (eg. background-color) and $var is the value name (eg. background). In the case of a nested map, $var is the nested value name. So if the property was font-size, the $var value would be body, not size. This is because our $props map lets us know to use the nested size map for the property font-size.

We'll have a third parameter that when overridden, will prevent the output of the fallback. This may be useful for some cases. We are going to call the @mixin v to be as concise as possible.

  // the variable mixin takes a property and variable name 
// and an optional override to hide the fallback
@mixin v($prop, $var, $show-fall: true) {
  // our mixin
}

The first thing we do in our @mixin is access the property's map information from the $props map so we can figure out what we need to do.

  $props: (
  background-color: color,
  font-family:      (font, family),
);

@mixin v($prop, $var, $show-fall: true) {
  // get the property's map name(s)
  $map-name: map-get($props, $prop);
}

If we said @include v(background-color, background), the $map-name would be color. If we said @include v(font-family, body), the $map-name would be (font, family). After we find the map information, we need to determine if it is a single value like color or a Sass list like (font, family).

  $props: (
  background-color: color,
  font-family:      (font, family),
);

// the variable mixin takes a property and variable name 
// and an optional override to hide the fallback
@mixin v($prop, $var, $show-fall: true) {
  // get the property's map name(s)
  $map-name: map-get($props, $prop);
  $nest-name: null;
  $nest-map-name: null;
  // if a list, we need to go deeper
  @if type-of($map-name) == list {
    $nest-name: nth($map-name, 1);
    $nest-map-name: nth($map-name, 2);
  }
}

If we said @include v(background-color, background), both the $nest-name and the $nest-map-name would be null. If we said @include v(font-family, body), the $nest-name would be font, and the $nest-map-name would be family. This happens because we figure out if $map-name is a list. If it is a list, we use the nth Sass function to get the first and second values from it and store them in the $nest- variables.

Now that we know if it is a nested map or a regular map, we can handle the two different scenarios.

  // the variable mixin takes a property and variable name 
// and an optional override to hide the fallback
@mixin v($prop, $var, $show-fall: true) {
  // get the property's map name(s)
  $map-name: map-get($props, $prop);
  $nest-name: null;
  $nest-map-name: null;
  $map: null;
  $var-fall: null;
  $var-output: null;
  // if a list, we need to go deeper
  @if type-of($map-name) == list {
    $nest-name: nth($map-name, 1);
    $nest-map-name: nth($map-name, 2);
  }
  // if it is a nested map
  @if $nest-name {
    // get the map from nested map-name
    $map: map-get($var-maps, $nest-name);
    // get the nested map
    $nest-map: map-get($map, $nest-map-name);
    // fallback value, get the var value from the nested map
    $var-fall: map-get($nest-map, $var);
    // our css4 variable output
    $var-output: var(--#{$nest-name}-#{$nest-map-name}-#{$var});
  } @else {
    // get the map from map name
    $map: map-get($var-maps, $map-name);
    // fallback value, grab the variable's value from the map
    $var-fall: map-get($map, $var);
    // our css4 variable output
    $var-output: var(--#{$map-name}-#{$var});
  }
  // if show fallback is not overridden to be null
  @if $show-fall {
    #{$prop}: $var-fall;
  }
  // css4 variable output
  #{$prop}: $var-output;
}

This is where our $var-maps "name-to-map" comes into play. In the @mixin, we need to prefix our CSS4 variable names with the name scoping in order to guarantee unique variable names.

Now, we have a @mixin that can handle our fallback and CSS4 variable output.

  body {
  @include v(background-color, background);
  @include v(font-family, body);
}

Outputs the following CSS.

  body {
  background-color: #F9F9F9;
  background-color: var(--color-background);
  font-family: Merriweather, Georgia, serif;
  font-family: var(--font-family-body);
}

Defining our vars with a loop

Rad. But don’t forget about our CSS4 variable definitions. We need to loop through our variable maps to output the definitions to the :root scope in order for our @mixin output to be valid CSS4. To do this, we can iterate over the $var-maps map we defined earlier and output the definitions.

  // setup the css4 variable definitions
:root {
  // for each variable map
  @each $var-map-name, $var-map in $var-maps {
    // for each variable in the variable map
    @each $var, $val in $var-map {
      // if it is a map, go another level deep
      @if type-of($val) == map {
        // each in the map
        @each $var-n, $val-n in $val {
          // do the definition
          #{--$var-map-name}-#{$var}-#{$var-n}: $val-n;
        }
      } @else {
        // do the definition
        #{--$var-map-name}-#{$var}: $val;
      }
    }
  }
}

This outputs the following CSS.

  :root {
  --color-text: #222;
  --color-background: #F0F0F0;
  --color-code-bg: #FFF;
  --font-family-body: Merriweather, Georgia, serif;
  --font-family-head: Roboto, Helvetica, sans-serif;
  --font-weight-body: 300;
  --font-weight-head: 300;
  --font-size-body: 18px;
  --font-size-h1: 2.4em;
  --font-size-h2: 2em;
  --font-size-h3: 1.6em;
  --font-size-h4: 1.4em;
  --font-leading-body: 1.7;
  --font-leading-head: 1.2;
}

Goldmine

Phew. We can now include CSS4 variables with fallbacks. The code should look something like this:

Recap

Ok. That was a lot of code. However, the bulk of this code is "paste it and be done with it". We have built a system that allows us to create and modify CSS4 variables with fallbacks that requires just about the same amount of work as setting them up in vanilla CSS. At a large enough scale, this ends up saving tons of time if you want to be on the bleeding edge.

But isn’t this redundant?

I know. You’re probably thinking that we’re achieving the exact same thing with Sass and CSS4 variables, so if we’re gonna use Sass anyway, what’s the point. There’s actually a big difference. Storing a variable is different than outputting one. Let’s say we are using a Sass variable called $accent-color and it is red. Then let’s say we include that variable all over the place on different classes and elements. If you wanted to modify the value of "color" in dev tools and see the result on the page, you would not really be able to do it. However, if --accent-color is a CSS4 variable, you could change it in one place in your dev tools, and everything using that CSS4 variable would update. That is the big difference here. If you think you'd only use CSS4 variables in development, you could put the CSS4 output of our @mixin and :root loop behind a flag that was false when compiling for production.

It is 2016 and we have variables in vanilla CSS. I’m excited about it. I figured I might as well try it.