Hashrocket.com / blog

Large untitled 1

Classical mixin inheritance in Sass

posted on and written by Cameron Daigle in

Image 100x100 cameron daigle

I had a crazy idea the other day, and it's grown into something pretty awesome (but still a little crazy): a pattern for scalably 'extending' Sass mixins in a sort-of-classical way.

The problem: extend and mixin aren't simultaneously awesome

Here's the problem I wanted to solve. Suppose I have a button that gets slightly lighter on :hover. One way to do this would be via simple subclasses, or perhaps Sass' @extend feature, like so.

%button-base
  // some other button styles here
  background: blue
  &:hover
    background: lighten(blue, 10%)

a.button
  @extend button-base

a.cancel-button
  @extend button-base
  background: red
  &:hover
    background: lighten(red, 10%)

This is inconvenient and scales poorly - I don't want to have to write my color math logic again for each new button subclass. Sass isn't doing a lot for me here.

But if I use @mixin instead of @extend, I get something new: arguments. I can pass a $color argument to my mixin, and it can operate on that one color to calculate the hover state. Like so:

@mixin button-base($color: blue)
  // some other button styles here
  background: $color
  &:hover
    background: lighten($color, 10%)

a.button
  @include button-base

a.cancel-button
  @include button-base($color: red)

Cool, right? But I'm still only halfway there: my other-button class is having to pass an argument to a mixin, when that other button might make more sense as a child mixin with its own defaults, so it can be injected elsewhere into my styles.

@mixin cancel-button
  @include button-base($color: blue)

Awesome. But here's the problem: @cancel-button doesn't know how to pass its own arguments to button-base unless you specify them directly ... so in an effort to expose properties consistently, you have to start writing some pretty silly stuff:

@mixin cancel-button($color: blue)
  @include button-base($color: $color)

Once you're dealing with lots of arguments, having to explicitly define those arguments becomes painful and redundant.

So, that's the setup. Thanks for sticking with me so far. This is the problem I decided to try and solve: could I create a system in which Sass mixins inherited properties in a semi-classical way, instead of having to write all of that stuff out?

Into the mouth of madness

What I've written is a set of mixins that takes advantage of Sass' rendering order to store & retrieve parameters outside of mixins. Here's how it works:

  • you only ever pass one argument to a mixin: a Sass map, which defaults to ().
  • you call a mixin named override and pass it that single argument. This essentially tells Sass that this mixin is a child of another mixin, and stores the argument map in a global store. (If the mixin isn't the end of the inheritance chain, set $is_child to false.)
  • you set default attributes for this child mixin by calling a default mixin and passing it an attribute & value pair.
  • Anywhere along the way, instead of accessing variables directly, you can call fetch to retrieve them from the global storage.

Here's what it looks like in action.

@mixin button-base($args: ())
  //button styles
  @include override($args, $is_child:false)
  @include default(color, blue)
  background: fetch(color)
  &:hover
    background: lighten(fetch(color), 10%)

@mixin cancel-button($args: ())
  @include override($args)
  @include default(color, red)
  @include button-base

There's a lot going on here, but this is the key takeaway: cancel-button no longer needs to know about the arguments used by button-base. This means that as you add arguments to button-base, you don't have to change anything about its children: cancel-button will automatically pass all arguments up the chain.

So, suppose I want to add support for a text-color argument, because I want to perform some color math on the text color and have it lighten on :hover. All I have to do is add two lines to button-base: a default value for text-color, and a style assignment.

@mixin button-base($args: ())
  //button styles
  @include override($args, $is_child:false)
  @include default(color, blue)
  @include default(text-color, yellow)       // set a new value
  background: fetch(color)
  color: fetch(text-color)                   // assign the new value
  &:hover
    background: lighten(fetch(color), 10%)   
    color: lighten(fetch(text-color), 10%)   // whee, color math

I haven't changed cancel-button at all, and yet I can do this:

.cancel-button
  @include cancel-button((text-color: purple))

Voila! cancel-button is "inheriting" all of the properties of button-base (and passes along arguments to it) without those properties having to be explicitly redefined. In a perfect world, this would be accomplished by some sort of actual extends command implemented natively, but until that day, this solution gives me something pretty special.

Under the Hood

Once you wrap your head around it, this inheritance model can be really powerful. Here's what's going on (I'm going to switch to mixin shorthand syntax here, because I like it):

Passing arguments as a Sass map

When you pass arguments with this technique, you're actually passing one single argument that is being stored in a global variable. override stores its $args map in this global store, overwriting whatever's there. If $is_child is true, it's the beginning of a new mixin inheritance chain, so it initializes the global store first.

$global_store: ()

= override($args:(), $is_child:true)
  @if $is_child
    $global_store: () !global
  $global_store: map-merge($global_store, $args) !global

As Sass parses the mixin inheritance chain, you can add more values by passing a key/value pair (or even a map of key/value pairs) to default:

// usage:
// +default(color, red)
// +default((color: red, background: blue))

= default($key_or_map, $value:null)
  @if $value
    +store-default($key_or_map, $value)
  @else
    @each $key, $value in $key_or_map
      +store-default($key, $value)

= store-default($key, $value)
  @if map-has-key($global_store, $key)
  @else
    $global_store: map-merge($global_store, ($key: $value)) !global

Finally, fetch retrieves the value from the global store.

// usage:
// color: fetch(color)

@function fetch($key)
  @return map-get($global_store, $key)

That's that. I'm calling it inheritance.sass, and it's in a gist here. Grab it and try your hand at it yourself – I'm currently using this technique on a client project, and it's actively making my life easier.

Posted in Design and tagged with SASS