Breakpoints and Tweakpoints in Sass

Kitty Giraudel
Share

A while back I, here on SitePoint, wrote Managing Responsive Breakpoints with Sass, presenting a couple of ways to deal with responsive design breakpoints using variables and mixins.

The last solution I introduced relies on a map of breakpoints, usually stored in the configuration file and a mixin taking a breakpoint name as the only argument, outputting a media query block. It should look like this:

// In `_config.scss`
$breakpoints: (
  'small'  : 767px,
  'medium' : 992px,
  'large'  : 1200px,
) !default;

// In `_mixins.scss`
@mixin respond-to($breakpoint) {
  @if map-has-key($breakpoints, $breakpoint) {
    @media (min-width: map-get($breakpoints, $breakpoint)) {
      @content;
    }
  }

  @else {
    @warn "Unfortunately, no value could be retrieved from `#{$breakpoint}`. "
        + "Please make sure it is defined in `$breakpoints` map.";
  }
}

// In `_random-component.scss`
.foo {
  color: red;

  @include respond-to('small') {
    color: blue;
  }
}

This solution is perfectly fine for many projects, from small to medium scale. Yet, when we start to have many components, it tends to fall a bit short.

Indeed, we can make a distinction between two different kinds of breakpoints:

  • actual breakpoints, involving layout recomposition;
  • component-specific breakpoints, helping polishing the look and feel of the page.

Layout related breakpoints are basically what we have in our previous example. They dictate when there is a major change in the layout, for instance when a column is added or removed from the grid. They allow the layout to change in order not to look broken, hence the name breakpoints.

But more often than not, layout breakpoints are not enough. A components behavior should not be dictated by them. Each specific component should have its own set of breakpoints, triggering micro-layout changes inside of this specific component. Those breakpoints are not actually breakpoints since layouts do not break without them, thus should be called tweakpoints, as suggested by Jeremy Keith in Tweakpoints.

In my experience, not all breakpoints are created equal. Sure, there are the points at which the layout needs to change drastically in order for the content not to look like crap—those media queries can legitimately be called breakpoints. But then there are the media queries that are used to finesse page elements without making any major changes to the layout.
[…]
It feels a bit odd to call them breakpoints, as though the layout would “break” without them. Those media queries are there to tweak the layout. They’re not breakpoints; they’re tweakpoints.
– From Jeremy Keith in Tweakpoints

The two are not mutually exclusive of course. Au contraire! You’ll often find yourself wanting to join major structure changes to your components on layout breakpoints if needed, and apply micro optimizations on component-specific tweakpoints.

In this article, we will overload our existing system to make it possible to deal with both application-wide breakpoints and component breakpoints.

What do we need?

My idea was to keep application-wide breakpoints in the configuration file (_config.scss or _variable.scss), but to also have a local tweakpoints map in each component file. For instance, let’s consider a logo, living in components/_logo.scss. To accommodate design changes, the logo happens to have two variations: an inline version, and a block version.

/// Logo-specific tweakpoints
/// @type Map
$tweakpoints: (
  'inline' : 650px,
  'block'  : 980px,
);

.logo { .. }
.logo__image { .. }
.logo__baseline { .. }

Inside _logo.scss, we have defined a $tweakpoints map that contains breakpoints (well, tweakpoints but you get the idea at this point) which are specific to this logo component. They only make sense in this context thus should only be available in this file. That being said, global breakpoints should also be available in case we actually need them for a major change or anything.

So what do we need? We only need to make our mixin aware of this map. It should first check if the requested breakpoint lives in the local map, then if not try on the global map.

Overloading the mixin

Surprisingly enough, our mixin is actually very simple and not much longer that the previous one:

/// Breakpoints/tweakpoints manager
/// @param {String} $point - Breakpoint/tweakpoint name
@mixin respond-to($point) {
  @if map-has-key($tweakpoints, $point) {
    @media (min-width: map-get($tweakpoints, $point)) {
      @content;
    }
  } @else if map-has-key($breakpoints, $point) {
    @media (min-width: map-get($breakpoints, $point)) {
      @content;
    }
  } @else {
    @warn "Could not find `#{$breakpoint}` in both local ($tweakpoints) and global ($breakpoints) contexts. Media block omitted.";
  }
}

There are three logical steps involved here:

  1. If the requested point exists in $tweakpoints, open a media query and leave the mixin.
  2. If the requested point does not exist in $tweakpoints but exists in $breakpoints, open a media query and leave the mixin.
  3. If the requested point does not exist in either $tweakpoints or $breakpoints, warn the user.

Note that if a breakpoint is present in both $tweakpoints and $breakpoints maps, the tweakpoint will override the breakpoint. For instance, if you have a medium tweakpoint and a medium breakpoint defined, the tweakpoint will be used when including respond-to('medium'). This is intended if we ever want to locally override a breakpoint for the current component (although this is not something I would recommend).

Using it

Using this fresh mixin is not any different than using our previous one. The API has not changed much, and every change would be fully backward compatible, which is very nice in my opinion.

<a href="/" class="logo">
  <img class="logo__image" src="..." alt="..." />
  <p class="logo__baseline">...</p>
</a>
/// Logo-specific tweakpoints
/// @type Map
$tweakpoints: (
  'inline' : 650px,
  'block'  : 980px,
);

/**
 * Logo wrapper
 * 1. Turn the link into a block level element
 * 2. Size it based on the narrowest child when in *block* format
 */
.logo {
  display: block; /* 1 */

  padding: .5em;
  color: currentcolor;
  text-decoration: none;

  @include respond-to('block') {
    width: min-content; /* 2 */
    margin: 1em auto;
    text-align: center;
  }
}

/**
 * Logo image
 * 1. Give it a max-width when inline'd so it does break out
 */
.logo__image {
  vertical-align: middle;

  @include respond-to('inline') {
    max-width: 3em; /* 1 */
  }

  @include respond-to('block') {
    display: block;
    height: auto;
    margin: 0 auto .5em;
  }
}

/**
 * Logo baseline
 * 1. Inline it when in *inline* format
 */
.logo__baseline {
  margin: 0;
  vertical-align: middle;

  @include respond-to('inline') {
    display: inline-block; /* 1 */
  }
}

Going further

Ideally, you want to reinitialize the $tweakpoints map at the end of the component file so it does not leak to the next partial. Something as simple as this is enough:

$tweakpoints: ();

This makes sure the next component does not have any tweakpoint configured. Problem is that it is very easy to forget this line, which is actually not that of a big deal if next component defines a $tweakpoints map itself. But if it does not, and the previous component overrides a global breakpoint with a tweakpoint, the next component will have the overridden breakpoint, not the global one.

For instance, consider this:

// In `_config.scss`
$breakpoints: (
  'small': 767px,
  'medium': 992px,
);
// In `_component-1.scss`
$tweakpoints: (
  'custom': 500px,
  'medium': 1170px;
);

// A lot of Sass rules here, but no `$tweakpoints: ();` at the end of file
// In `_component-2.scss`

// No `$tweakpoints` map defined for this component
// In `main.scss`
@import "utils/config";

@import "components/component-1";
@import "components/component-2";

Now, if we happen to have @include respond-to('medium') in _component-2.scss, the breakpoint’s value will be 1170px, not 992px. This happens because the previous component (_component-1.scss) overrides the global medium with a local value, and does not reset the tweakpoints map at the end of file.

Okay, so this sucks and is very error prone. We could surely do better!

Indeed we can. What if we had a component mixin that takes care of assigning and reseting $tweakpoints? It is basically a wrapper for our component.

/// Component wrapper
/// @param {Map} $component-tweakpoints [()] - Component tweakpoints
@mixin component($component-tweakpoints: ()) {
  $tweakpoints: $component-tweakpoints !global;
  @content;
  $tweakpoints: () !global;
}

That’s all we need. Now, let’s rewrite our previous example:

// In `_config.scss`
$breakpoints: (
  'small': 767px,
  'medium': 992px,
);
// In `_component-1.scss`
@include component((
  'custom': 500px,
  'medium': 1170px,
)) {
  // A lot of Sass rules here, but no `$tweakpoints: ();` at the end of file
}
// In `_component-2.scss`

@include component {
  // No `$tweakpoints` map defined for this component
}
// In `main.scss`
@import "utils/config";

@import "components/component-1";
@import "components/component-2";

That’s it! No more issue because the component mixin took care of resetting the $tweakpoints map at the end of the component, making sure there is no unfortunate leak.

Final thoughts

To be perfectly honest with you guys, I have not tested this solution in a real life project just yet, but I do think it is a valuable extra feature to breakpoint management. Thanks to this little addition, it is getting very easy to handle both application-wide breakpoints and component-specific tweakpoints.

I should probably warn you about a potential drawback of this method: wrapping each component in a mixin call (@include component(..) { .. }) prevents you from declaring component-specific mixins, since you cannot define a mixin within a mixin call. That being said, I am not a big fan of having component-specific mixins and functions, so it might not be an actual drawback if you happen to think like me.

I would like to finish this article by mentioning that Sass-MQ from Kaelig allows something equivalent.

// _random-component.scss
$tweakpoints: (
  'custom': 500px,
  'medium': 1170px,
);

.foo {
  @include mq('medium') {
    color: red;
  }

  @include mq('medium', $breakpoints: $tweakpoints) {
    color: blue;
  }
}

As you can see, the mq mixin accepts a breakpoints map as an extra argument, which defaults to $mq-breakpoints (the map used by Sass-MQ to store global breakpoints). If you want to make the API slightly more convenient, you can always write your own little mixin:

@mixin component-mq($from: false, $until: false) {
  @include mq($from: $from, $until: $until, $breakpoints: $tweakpoints) {
    @content;
  }
}

Then you use it like this:

.foo {
  // Global medium: 992px
  @include mq('medium') {
    color: red;
  }

  // Local medium: 1170px
  @include component-mq('medium') {
    color: blue;
  }
}

That’s it! I hope you like it, and if you have any suggestion be sure to share in the comments! :)