Design
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.
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
tofalse
.) - 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.