David Walsh Blog

Write Simple, Elegant and Maintainable Media Queries with Sass

I spent a few months experimenting with different approaches for writing simple, elegant and maintainable media queries with Sass. Each solution had something that I really liked, but I couldn’t find one that covered everything I needed to do, so I ventured into creating my own: meet include-media.

I started off with this basic list of requirements:

So, basically, I wanted something like this:

// We'll define the breakpoints somehow. 
// For now, let's say tablet' is 768px and 'desktop' 1024px.

@include media(">=tablet", "<1280px") {

}

@include media("screen", ">tablet") {

}

@include media(">tablet", "retina2x") {

}

// Compiling to:

@media (min-width: 768px) and (max-width: 1279px) {

}

@media screen and (min-width: 769px) {

}

@media (min-width: 769px) and (-webkit-min-device-pixel-ratio: 2),
       (min-width: 769px) and (min-resolution: 192dpi) {

}

Let’s get to it then.

Parsing expressions

The first step is to come up with a structure where we can define our breakpoints and our media types and expressions. Sass 3.3 added support for Maps, which are basically multidimensional lists where values can be associated similarly to JSON. This is the perfect structure to accommodate our breakpoints.

$breakpoints: (phone: 320px, tablet: 768px, desktop: 1024px) !default;

This is flexible enough to store any number of breakpoints we want, and we automatically label them for easier usage. Because we’ll most likely be tweaking these breakpoints on a project basis, we declare it as !default in the mixin and then override the declaration wherever necessary.
Similarly, we can use this structure to store our media types and expressions.

$media-expressions: (screen: "screen", 
                    print: "print", 
                    handheld: "handheld",
                    retina2x: ("(-webkit-min-device-pixel-ratio: 2)", "(min-resolution: 192dpi)"), 
                    retina3x: ("(-webkit-min-device-pixel-ratio: 3)", "(min-resolution: 350dpi)")
                    ) !default;

Note that I’ve declared expressions containing logical disjunctions as nested lists, because we’ll have to deal with their conditions individually. We could still declare them as strings separated by a comma, as they are naturally written in CSS, and then break them apart, but we might as well split them at the beginning.

The next step is to parse the strings we receive as arguments and translate them into the correct expressions. Instead of trying to write a mixin that does all that at once, let’s go for a smaller function that handles a single argument (e.g. >=tablet into min-width: 768px).

@function parse-expression($expression) {
  $operator: "";
  $value: "";
  $element: "";
  $result: "";
  $is-width: true;

  // Separating the operator from the rest of the expression
  @if (str-slice($expression, 2, 2) == "=") {
    $operator: str-slice($expression, 1, 2);
    $value: str-slice($expression, 3);
  } @else {
    $operator: str-slice($expression, 1, 1);
    $value: str-slice($expression, 2);
  }

  // Checking what type of expression we're dealing with
  @if map-has-key($breakpoints, $value) {
    $result: map-get($breakpoints, $value);
  } @else if map-has-key($media-expressions, $expression) {
    $result: map-get($media-expressions, $expression);
    $is-width: false;
  } @else {
    $result: to-number($value);
  }

  // If we're dealing with a width (breakpoint or custom value), 
  // we form the expression taking into account the operator.
  @if ($is-width) {
    @if ($operator == ">") {
      $element: "(min-width: #{$result + 1})";
    } @else if ($operator == "&lt;") {
      $element: "(max-width: #{$result - 1})";
    } @else if ($operator == ">=") {
      $element: "(min-width: #{$result})";
    } @else if ($operator == "&lt;=") {
      $element: "(max-width: #{$result})";
    }
  } @else {
    $element: $result;
  }

  @return $element;
}

We start by detecting which operator has been used and then match the rest of the string against the breakpoints map. If it matches one of the keys, we’ll use its value. If not, we repeat the process for the media expressions map. Finally, if no match is found, we assume it’s a custom value in which case we have to cast the string into a number we can use to add or subtract as needed, depending on the operator. I’ve used to-number from SassyCast to do that.

Handling logical disjunction

Because of the way the or works in CSS, combining conditions containing that operator with others can be quite tricky. For example, the expression a (b or c) d e (f or g) will generate 4 branches of disjoint conditions.
Here’s a process we can use to generate all those possible combinations:

  1. Take a “snapshot” of the expression, leaving singletons untouched and taking only the first element of each or group (a “snapshot” of an expression with no groups is the original expression).
  2. Find the next disjunction (group). Take the expressions already calculated (result) and make (N-1) copies, where N is the number of elements in the group.
  3. Replace all the instances of the first element of the group with every other element of the group and update the result.
  4. Repeat step 2 until there are no more groups.

The following table illustrates the process for the expression shown above.

Iteration Group Result
1 a b d e f (snapshot)
2 (b, c) a b d e f, a c d e f
3 (f, g) a b d e f, a c d e f, a b d e g, a c d e g

Here’s how we can write that in Sass:

@function get-query-branches($expressions) {
  $result: "";
  $has-groups: false;

  // Getting initial snapshot and looking for groups
  @each $expression in $expressions {
    @if (str-length($result) != 0) {
      $result: $result + " and ";
    }

    @if (type-of($expression) == "string") {
      $result: $result + $expression;
    } @else if (type-of($expression) == "list") {
      $result: $result + nth($expression, 1);
      $has-groups: true;
    }
  }

  // If we have groups, we have to create all possible combinations
  @if $has-groups {
    @each $expression in $expressions {
      @if (type-of($expression) == "list") {
        $first: nth($expression, 1);

        @each $member in $expression {
          @if ($member != $first) {
            @each $partial in $result {
              $result: join($result, str-replace-first($first, $member, $partial));
            }
          }
        }
      }
    }
  }

  @return $result;
}

Sass doesn’t have any string replace function, so I created a simple version that replaces only the first occurrence of the string to be replaced which is what we need:

@function str-replace-first($search, $replace, $subject) {
  $search-start: str-index($subject, $search);

  @if $search-start == null {
    @return $subject;
  }

  $result: str-slice($subject, 0, $search-start - 1);
  $result: $result + $replace;
  $result: $result + str-slice($subject, $search-start + str-length($search));

  @return $result;
}

Gluing everything together

Now that we built our little helpers, we can finally write the mixin itself. It’ll have to take a variable number of strings, parse them, detect media expressions and handle possible disjunctions and glue everything together to form the media query expression.

@mixin media($conditions...) {
  @for $i from 1 through length($conditions) {
    $conditions: set-nth($conditions, $i, parse-expression(nth($conditions, $i)));
  }

  $branches: get-query-branches($conditions);
  $query: "";

  @each $branch in $branches {
    @if (str-length($query) != 0) {
      $query: $query + ", ";
    }

    $query: $query + $branch;
  }

  @media #{$query} {
    @content;
  }
}

Wrapping it up

This is implementation is a bit complex and some people might argue it’s just too much code for writing media queries. Personally, I think this is an easy and comfortable way to write powerful and maintainable media queries with a simple and comfortable syntax, just by downloading and importing a single SCSS file.
The GitHub repo is there so people can submit issues and pull requests and that’ll hopefully keep the mixin up-to-date with new features and improvements. What are you thoughts?