Applying a basic templating strategy such as you might see in JSX, handlebars or any number of other HTML templating libraries to Sass allows for a powerful, straight-forward syntax. The approach requires:

  1. Something capable of creating the final ouput format while accepting an input argument.
  2. The capability to nest.
  3. A compatible data object.

Of course, many of the HTML templating libraries do other things that are irrelevant in this context, like event propagation and virtual DOM implementation, which is why the focus will be on the 3 requirements outlined above.

TL;DR

Sass mixins are the key to this implementation since they are capable of outputing actual CSS and accepting function arguments. Mixins have always been capable of meeting the first two requirements. As of 3.3, it can meet all of them thanks to the introduction of Sass maps. Converting all CSS output to

  1. Make everything a mixin
  2. Use a single Sass map argument, $props, for any given mixin
  3. Have every declaration be a look-up in that map

allows the application of a templating pattern to Sass.

Basic Example

Here’s a fairly standard mixin from Bootstrap 4, one of their many that helps create a button:

@mixin button-size($padding-y, $padding-x, $font-size, $line-height, $border-radius) {
  padding: $padding-y $padding-x;
  font-size: $font-size;
  line-height: $line-height;
  // Manually declare to provide an override to the browser default
  @if $enable-rounded {
    border-radius: $border-radius;
  } @else {
    border-radius: 0;
  }
}

To convert it,

@mixin button-size($props) {
  padding: map-get($props, 'padding');
  font-size: map-get($props, 'font-size');
  line-height: map-get($props, 'line-height');
  border-radius: map-get($props, 'border-radius');
}

And then to implement it

// index.scss
@import 'button-size';

.btn {
  @include button-size(
    (
      padding: 5px 10px,
      font-size: 12px,
      line-height: 1em,
      border-radius: 10px
    )
  );
}

What’s so bad about the conventional one?

The Bootstrap mixin exhibits a lot of standard practice: Every CSS declaration has an input, and they’re all represented in the argument list. Also, no arguments are supplied with default values, so they are all required. In that, this mixin becomes rather inflexible. Because it specifically requires 2 separate values for padding, one may not use this in any other way, eg, to make the padding uniform, one would have to repeat the value, or to have an additional padding parameter seems impossible without direct modification of the mixin and its arguments, resulting in a breaking change. Calling it is also conventional and might look like this,

@include button-size(5px, 5px, 12px, 1em, 10px);

which may be terse, but also not a little inscrutible. Even if the data is provided only via variables, without the arguments, it’s easy to forget what the mixin is asking for. To improve that, the argument names can be directly added in the call and given their own line

@include button-size(
  $padding-y: 5px,
  $padding-x: 5px,
  $font-size: 12px,
  $line-height: 1em,
  $border-radius: 10px
);

With named arguments, the arguments can be re-ordered arbitrarily, but, it’s becoming obvious that the mixin can be applied in rather unexpected ways. For example,

@include button-size(
  $padding-x: 5px 10px 3px,
  $padding-y: null,
  ...

can pass in the alternate number of padding values that we noted were missing earlier. This may get us what we want, but worsens comprehensibility because now something called padding-x is clearly supplying something different. It is also in no way future-proofed. If a maintainer notices this semantic mismatch, they might address it by adding some guards to ensure that only single values get passed in to the arguments.

Another issue is bloat. If we wanted to have one general rule for buttons that we could reuse in our HTML, but make one modification, we would either have to add a great deal of unneeded declarations if we used the mixin, or hand-code some CSS that does it.

How’s the alternative any better?

First let’s break the pieces a little further apart by creating a standalone variable consisting of a Sass map that would satisfy a conventional implementation:

// button-size-defaults.scss
// default data for a button
$button-size-defaults: (
  padding: 5px 10px,
  font-size: 12px,
  line-height: 1em,
  border-radius: 10px
);

// button-size-template.scss
// template for a button
@mixin button-size-template($props) {
  padding: map-get($props, 'padding');
  font-size: map-get($props, 'font-size');
  line-height: map-get($props, 'line-height');
  border-radius: map-get($props, 'border-radius');
}

// index.scss
// bringing it together
@import 'button-size-defaults';
@import 'button-size-template';
.btn {
  @include button-size-template($button-size-defaults);
}

Right away a separation of concerns is achieved: By having a Sass map distinct from the mixin itself, it becomes easier to understand what the expectations of the mixin are, because the mixin is stating it, not with a mysteriously ordered argument set, but with key names stating their expectation. Also, the conditional statement has been removed around the border-radius (more on that a little later). Most importantly, there is a base object that contains default values but is still freely modifiable through conventional data manipulation, eg:

@import 'button-size-defaults';
@import 'button-size-template';

$btn-mod: map-merge(
  $button-size-defaults,
  (
    padding: 20px,
    font-size: 16px
  )
);

.btn {
  @include button-size-template($btn-mod);
}

The above now states explicitly that after starting with default values, use these handful of changes. Those changes are explicitly documented, and, because Sass doesn’t mutate data, the defaults will continue to be available. However, the real power comes when we actually remove the defaults and supply our own data with certain values missing because

  • Sass removes an entire declaration where the right-side is empty or the value is null.
  • Sass removes the selector when all the declarations of a given selector have been removed.
  • When map-get fails, Sass returns a null.

If a mixin like the one above gets passed an object that has data missing, the lines referencing those data points simply disappear:

@import 'button-size-template';

$btn-data: (
  padding: 20px,
  font-size: 16px
);

.btn {
  @include button-size-template($btn-data);
}

creates

.btn {
  padding: 20px;
  font-size: 16px;
}

The mere abscence of data points makes the declaration optional. In other words, it’s as though each declaration is wrapped in a conditional statement. This allows the mixin to be defined once, and reused multiple times with very different outcomes.

Further, this makes it absolutely clear that CSS values are data and they are manipulable in any way that one may manipulate data. The template mixin is what handles the actual output of the values, but before being passed to it, the data remains independent and is freely transformable including being selectively deleted without fear of breaking it.

border-radius thus doesn’t need a special conditional to wrap it: the declaration will get removed just like any other when it encounters a null value. To re-create the intention of the original conditional block, one would only need to selectively provide or remove a border-radius key from a Sass map getting passed to the button-size-template.

Future posts will look at elaborations, uses and discussions of using this combination of Sass mixins and map objects as a CSS templating syntax. In the meantime, here’s a gist to play with over at SassMeister, including one of many possible ways of manipulating the default data set to include or exclude the border-radius declaration, using the same closure vairable.