Cross-Media Query @extend Directives in Sass
If you read my previous article on Sass’s @extend directive or if you have pretty solid knowledge about the way Sass works, you may be aware that you cannot do this:
%example {
color: blue;
font-weight: bold;
font-size: 2em;
}
@media (max-width: 800px) {
.generic-class {
@extend %example;
}
}
What’s happening here is we’re trying to extend a placeholder inside a media query from another scope (in this case, the root). It just doesn’t work, and if you try it you’ll get the following error message:
You may not @extend an outer selector from within @media.
You may only @extend selectors within the same directive.
That sucks, doesn’t it? So the other day, I rolled up my sleeves and dug deep into the code to find a way to create an @extend
directive that works across media queries. It was an amazing journey and eventually I ended up with a not-so-terrible solution. However keep in my mind that this technique is basically a hack, so it’s mostly experimental. But if you can’t wait for a native Sass solution, I suppose you could give this a try.
How Does it Work?
This is a very complex solution, so you may have to go over this a few times. First, it relies on knowing the existing @media
scopes from the beginning. Generally, this shouldn’t be a problem because our code will usually have a list of named breakpoints. Next, the key is to create the same placeholder in every scope so no matter what scope we’re currently inside, we can use the placeholder. Then, the tricky part is to know which scope we’re currently in. For example: Are we at the root level? Are we inside a small breakpoint? A large one? Not a simple task.
Because this is quite complicated, the resulting Sass code is pretty heavy. That being said, I have a motto:
It doesn’t matter how complicated the core code is, as long as the API is simple.
So that’s really what I was aiming for: a simple API so that the developer doesn’t have to do a ton of complicated stuff just to extend a placeholder from a media query.
Before getting to the step-by-step build of the code that our API will be based on, let’s look at the API itself, comparing it to what we might normally do with a placeholder:
/* Regular single-scope placeholder */
%my-placeholder {
property: value;
}
/* Enhanced multi-scope placeholder */
@include placeholder(my-placeholder) {
property: value;
}
While the multi-scope version (2nd example above) is a little longer to type than the standard placeholder syntax, it is just as easy to understand. Also, the fact that it’s slightly longer shouldn’t matter much since you don’t create placeholders every two minutes. To shorten this, you could rename it to ph()
or even p()
, or create aliases for it.
Now that we’ve defined our placeholder, we can extend it. Here is how we would do it, with an example first of how you would normally do it without the cross-scope benefits:
/* Regular @extend, with cross-scope limitations */
@extend %my-placeholder;
/* Enhanced cross-scope @extend */
@include _(my-placeholder);
Okay, now I know it looks weird, but before puking, hear me out: Since we need to pass the name of the placeholder as a parameter to a mixin, I gave the mixin (which you’ll see defined later) a very short name (in this case, one character). This keeps it brief and easy to use.
At first, I wanted to use %()
to be consistent with the more common placeholder syntax. However, doing this would require that the % character be escaped with a backslash \
(e.g. \%(my-placeholder)
). This would defeat the purpose and make it a pain to use. Hence the underscore: _()
.
Note: remember that Sass considers underscores and hyphens the same. So you could use @include -(my-placeholder)
instead. Otherwise, feel free to create the alias you want — extend()
, gloubiboulga()
, god-i-love-sass-so-much()
, or whatever.
In the end, I think the API is not that bad. It may look odd at first but I think you’ll get used to it pretty quickly so it should be good enough.
Writing the Library for our API
Let’s sum up what we need in order to make the whole thing work:
- A couple of settings, including a list of breakpoints
- A mixin to handle media queries (let’s call it
breakpoint()
) - A mixin to generate a placeholder (
placeholder()
) - A mixin to extend a placeholder (
_()
)
From this point on, you can copy and paste each of the code examples into a Sass file, in the order they appear (or into a tool like SassMeister), to slowly build the code that’s used by the API discussed in the previous sections. You can also view the final code in a Gist on SassMeister.
Set Up and Configuration
First, the configuration. As I said, from the beginning we need to know the different breakpoints (which are our media query scopes). For this, we can use a simple map associating a keyword with a max-width
value. Like this:
$breakpoints: (
"small" : 600px,
"medium" : 900px,
"large" : 1200px
);
Naturally, you can pick the names you want and the width values you want. I decided to keep things simple with 3 very basic keywords and 3 standard widths for the demo, but feel free to do as you want.
Next we need a variable to store the current breakpoint (or scope) we’re in. Let’s call it $current-breakpoint
. We also need to give it a default value, which can be anything. I decided to call it root
, because we start at root level.
$default-breakpoint: root;
$current-breakpoint: $default-breakpoint;
Lastly, we need a variable to store the names of all generated placeholders. Don’t worry, this variable will be auto-filled. At first, no placeholder exists so we initialize it as an empty list.
$placeholders: ();
I think we’re good with the set up!
The Media Query Mixin
The media query mixin is meant to open a new scope. This means instead of manually opening a @media
scope with @media (min-width: 900px) {}
, we instead include the breakpoint
mixin by passing it a keyword (e.g. @include breakpoint(small) {}
). Of course, the keyword has to be defined in the $breakpoint
map (that’s the whole point of it). Then, the mixin opens the right scope for us.
Where it’s getting interesting is this mixin also updates the $current-breakpoint
variable to keep track of the current scope. Here is the code, with comments to make each section clear:
@mixin breakpoint($breakpoint) {
// Get the width from the keyword `$breakpoint`
// Or `null` if the keyword doesn't exist in `$breakpoints` map
$value: map-get($breakpoints, $breakpoint);
// If `$breakpoint` exists as a key in `$breakpoints`
@if $value != null {
// Update `$current-breakpoint`
$current-breakpoint: $breakpoint !global;
// Open a media query block
@media (min-width: $value) {
// Let the user dump content
@content;
}
// Then reset `$current-breakpoint` to `$default-breakpoint` (root)
$current-breakpoint: $default-breakpoint !global;
}
// If `$breakpoint` doesn't exist in `$breakpoints`,
// Warn the user and do nothing
@else {
@warn "Invalid breakpoint `#{$breakpoint}`.";
}
}
As you can see, right before dumping user content (@content
), we update the $current-breakpoint
variable with the keyword passed to the mixin (so in our case small
, medium
or large
), and right after it’s dumped we reset it to root
(i.e. our $default-breakpoint
variable).
Note: the !global
flag has been added to Sass 3.3 to make a distinction between defining a scoped variable and redefining a global variable. In our case, we are overriding $current-breakpoint
, which is global.
The Mixin to Generate the Placeholder
Our mixin is fairly straightforward. However, note the one verification we make before doing anything: We only generate the placeholders if they do not exist yet. To make sure they don’t, we check the name passed to the mixin against the list of existing placeholders ($placeholders
). If it doesn’t exist in the list yet, we add it and then generate the placeholders. This makes sure a placeholder cannot be generated twice, which would lead to an error.
@mixin placeholder($name) {
// If placeholder doesn't exist yet in `$placeholders` list
@if not index($placeholders, $name) {
// Store its name
$placeholders: append($placeholders, $name) !global;
// At root level
@at-root {
// Looping through `$breakpoints`
@each $breakpoint, $value in $breakpoints {
// Opening a media query block
@media (min-width: $value) {
// Generating a placeholder
// Called $name-$breakpoint
%#{$name}-#{$breakpoint} {
@content;
}
}
}
// And dumping a placeholder out of any media query as well
// so basically at root level
%#{$name}-#{$default-breakpoint} {
@content;
}
}
}
// If placeholder already exists, just warn the user
@else {
@warn "Placeholder `#{$name}` already exists.";
}
}
Extending the Placeholder
All we have left is our short mixin to extend a placeholder no matter the media block we’re in:
@mixin _($name) {
@extend %#{$name}-#{$current-breakpoint} !optional;
}
This is where we actually use the $current-breakpoint
value, to extend the accurate placeholder from the scope we’re in. The !optional
flag is here only as a security measure. If, for any reason, the placeholder doesn’t exist (which shouldn’t happen, but you never know), the @extend
won’t crash.
A Working Example
Let’s create a simple example as a proof of concept: A clearfix placeholder. We’ll keep it simple; it dumps a clear: both
for simple float clearing, and overflow: hidden
for inner float clearing. That’s definitely not the best clearfix method; we’re just using it for our purposes here.
First, we need to create the placeholder:
@include placeholder('clear') {
clear: both;
overflow: hidden;
}
And now we can use it:
.a {
@include _(clear);
}
.b {
@include _(clear);
}
.c {
@include breakpoint(medium) {
@include _(clear);
}
}
@include breakpoint(medium) {
.d {
@include _(clear);
}
}
.e {
@include _(clear);
@include breakpoint(large) {
@include _(clear);
}
}
This will result in the following CSS:
@media (min-width: 900px) {
.c, .d {
clear: both;
overflow: hidden;
}
}
@media (min-width: 1200px) {
.e {
clear: both;
overflow: hidden;
}
}
.a, .b, .e {
clear: both;
overflow: hidden;
}
Not bad, is it?
Final thoughts
So in the end, we managed to create a mixin able to extend a placeholder no matter the @media
block we’re in, resulting in some neat and optimized CSS output. Also, in my opinion, the API is not much more complicated than the usual %placeholder {}
/@extend %placeholder
workflow.
Now, is it really useful? If you ask me, I’m not sure. As far as I’m concerned, I have yet to face a case where I truly needed a cross-scope @extend
. I think it happened to me only once and I managed to work around the issue without much difficulty. I feel like mixins and placeholders usually define the core of an element, which means they should be applied at the root level and not at a given breakpoint precisely – at least, that’s how I do it in my code.
Besides that, using a mixin when stuck in a @media
block with no access to root placeholders is probably way simpler. Also, gzip aggressively crushes repeated strings so I’m not sure using mixins instead of placeholders is that bad an idea when we’re concerned about final file size, but that’s another story.
In any case, this is a fun experiment to play with. Hope you liked it! By the way, you can play with the code at SassMeister!