🤯 50% Off! 700+ courses, assessments, and books

Sass Mixin and Media Merging

Kitty Giraudel
Share

If you are not very familiar with Sass, you might not be aware that Sass enhances media queries (actually the @media directive) to provide extra interesting features. One of which is called (or rather often referred to as) media merging.

Before explaining to you what media merging is, did you know that the CSS specifications on Media Queries actually allow media queries to be nested? Some browsers like Firefox, Chrome and Opera support it; some other like Safari and Internet Explorer currently do not.

Because the browser support is not ideal, Sass kicks in and merges nested media queries into a single media condition. For instance, consider this code:

@media (min-width: 42em) {
  @media (max-width: 1337px) {
    .foo {
      color: red;
    }
  }
}

This will be compiled as:

@media (min-width: 42em) and (max-width: 1337px) {
  .foo {
    color: red;
  }
}

Pretty handy, isn’t it? This is what is called media merging. When nested media queries are merged into a single statement.

What do we want? Media queries!

Now that I have done with the introduction, let me get to the point. The other day, I was playing with this idea. Basically, I wanted to build a very simply mixin that takes a map of queries as input, and merge them into a single condition in a @media directive as an output.

Coming back to the previous example, I would like to write something like this:

@mixin media($queries) { .. }

.foo {
  @include media((min-width: 42em, max-width: 1337px)) {
    color: red;
  }
}

And when compiling, having the same result as seen in the CSS snippet above. Now, on the top of my head I can think of at least two ways of building it. Let’s tackle the ugly one first.

The ugly version

The most straight-forward idea would be to build a string out of our map. We iterate over the map, and for each new key/value pair, we concatenate them into the result string, separating pairs with the and keyword.

/// Media query merger (the ugly version)
/// Create a single media condition out of a map of queries
/// @param {Map} $queries - Map of media queries
@mixin media($queries) {
  $media-condition: ';

  // Loop over the key/value pairs in $queries
  @each $key, $value in $queries {
    // Create the current media query
    $media-query: '(' + $key + ': ' + $value + ')';

    // Append it to the media condition
    $media-condition: $media-condition + $media-query;

    // If pair is not the last in $queries, add a `and` keyword
    @if index(map-keys($queries), $key) != length($queries) {
      $media-condition: $media-condition + ' and ';
    }
  }

  // Output the content in the media condition
  @media #{$media} {
    @content;
  }
}

Okay, it’s not that ugly. It does the job fine, but you have to admit that this is not very elegant, is it?

The elegant way

I don’t feel very comfortable manipulating strings when Sass provides such an elegant way to to deal with media queries. There surely is a better way to do this. And then it stroke me: recursion. According to the free dictionary, recursion is:

A method of defining a sequence of objects, such as an expression, function, or set, where some number of initial objects are given and each successive object is defined in terms of the preceding objects. The Fibonacci sequence is defined by recursion.

That’s a bit tough. If we put this very simply, recursion is a mechanism where a function calls itself over and over again with different arguments until a certain point. A practical example of a function using recursion in JavaScript would be:

function factorial(num) {
  if (num < 0) return -1;
  else if (num == 0) return 1;
  else return (num * factorial(num - 1));
}

As you can see, the function calls itself until the num variable gets lesser than 1, by decreasing it by 1 at every run.

Why am I telling you this? I figured out we could use recursion to build our media condition using Sass media merging. What if we make the mixin output a media for the first query in the map, then call itself passing the map without this query, until there is no query left in the map? Let’s try it, and since it might be a bit complex, we’ll go step by step.

First, we now that if our map contains no (more) query, we simply output the content. What don’t we start with this?

@mixin media($queries) {
  @if length($queries) == 0 {
    @content;
  } @else {
    // ...
  }
}

Now, we want to output a media block for the first media query in the map. To get the first key of a map, we can use nth(..) and map-keys(..) functions.

$first-key: nth(map-keys($queries), 1);

@media ($first-key: map-get($queries, $first-key)) {
  // ...
}

So far, so good. Now, we only need to make the mixin call itself although we don’t want to pass it the same $queries map or we will face an infinite loop. We need to pass it $queries after having removed the first key/value pair. Thankfully, there is the map-remove(..) function for this.

$queries: map-remove($queries, $first-key);

@include media($queries) {
  @content;
}

Now the whole mixin:

/// Media query merger
/// Create a single media condition out of a map of queries
/// @param {Map} $queries - Map of media queries
@mixin media($queries) {
  @if length($queries) == 0 {
    @content;
  } @else {
    $first-key: nth(map-keys($queries), 1);

    @media ($first-key: map-get($queries, $first-key)) {
      $queries: map-remove($queries, $first-key);

      @include media($queries) {
        @content;
      }
    }
  }
}

Going further

In a previous article, we saw a couple of different ways to manage responsive breakpoints in Sass. The last version used a mixin that looks like this:

/// Breakpoints map
/// @type Map
$breakpoints: (
  'small': (min-width: 767px),
  'medium': (min-width: 992px),
  'large': (min-width: 1200px),
);

/// Responsive breakpoint manager
/// @param {String} $breakpoint - Breakpoint
/// @requires $breakpoints
@mixin respond-to($breakpoint) {
  $media: map-get($breakpoints, $breakpoint);

  @if not $media {
    @error "No query could be retrieved from `#{$breakpoint}`. "
    + "Please make sure it is defined in `$breakpoints` map.";
  }

  @media #{inspect($media)} {
    @content;
  }
}

This mixin works like a charm but it does not support multiple-queries conditions such as (min-width: 42em) and (max-width: 1337px) because it relies on the inspect(..) function which does nothing more than print the Sass representation of the value.

So, on one hand we have a breakpoint manager which picks from a global map of breakpoints and handle error messages and on the other with have a breakpoint manager which allow the use of multiple-queries conditions. The choice is hard.

Or is it?

By slightly tweaking the respond-to(..) mixin, we can make it include the media(..) mixin instead of printing a @media directive itself. Then, we have the best of both worlds.

@mixin respond-to($breakpoint) {
  // Get the query map for $breakpoints map
  $queries: map-get($breakpoints, $breakpoint);

  // If there is no query called $breakpoint in map, throw an error
  @if not $queries {
    @error "No value could be retrieved from `#{$breakpoint}`. "
    + "Please make sure it is defined in `$breakpoints` map.";
  }

  // Include the media mixin with $queries
  @include media($queries) {
    @content;
  }
}

The best thing is, if you already use this mixin, you can totally include the multi-queries feature by tweaking respond-to(..) and adding media(..) because the API does not change at all: respond-to(..) still needs a breakpoint name to work, the same as before.

Final thoughts

I must say I find this very exciting because it is the first time I have found a good use-case for both nested media queries and mixin recursion. While it is possible to skip this and simply build a string as we’ve seen with our first version, it sure is more elegant and interesting to tackle it with a recursive mixin. I hope you enjoyed it! The final example before leaving:

// _variables.scss
$breakpoints: (
  'small': (min-width: 767px),
  'small-portrait': (min-width: 767px, orientation: portrait),
  'medium': (min-width: 992px),
  'large': (min-width: 1200px),
);

// _mixins.scss
@mixin media($queries) { .. }
@mixin respond-to($breakpoint) { .. }

// _component.scss
.foo {
  @include respond-to('small-portrait') {
    color: red;
  }
}

Yielding the following CSS:

@media (min-width: 767px) and (orientation: portrait) {
  .foo {
    color: red;
  }
}