Classical mixin inheritance in Sass
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.
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
overrideand 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
- you set default attributes for this child mixin by calling a
defaultmixin and passing it an attribute & value pair.
- Anywhere along the way, instead of accessing variables directly, you can call
fetchto 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))
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
// 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
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.