Skip to content

Instantly share code, notes, and snippets.

@fabricelejeune
Last active March 16, 2023 23:52
Show Gist options
  • Save fabricelejeune/bcdd3d4725d4e4cea672 to your computer and use it in GitHub Desktop.
Save fabricelejeune/bcdd3d4725d4e4cea672 to your computer and use it in GitHub Desktop.
Efficient font stack with Sass

Efficient font stack with Sass

The strength of Sass is the mixins and functions. Being able to automate many of the repetitive coding for CSS is both amazing in building and maintaining a clean and efficient code. I often find many developers creating complex systems for simple tasks, such as managing a font stack. This can be tedious to set up and employ. In this article, I will explain how I automate this system.

The font stack is one of those problems which are often solved by simple variables. In this instance, it makes a lot of sense and is easy enough to work with. But when you work with our (beloved) designers from Dogstudio, you can be sure of having to use lot of font variants. It quickly happens that I do not remember all the properties of each variants. And when I say "use lot of font variants", I mean at least 15 in most cases.

Sass maps to the rescue

Instead of simply define variables, I will ceate a font stack map and a mixin to use the map easily.

$base-font-stack: (
  // Sans-serif
  helvetica: (
    light: (
      family: (Helvetica Neue, Helvetica, Arial, sans-serif),
      weight: 200,
      style: normal
    ),
    light-italic: (
      family: (Helvetica Neue, Helvetica, Arial, sans-serif),
      weight: 200,
      style: italic
    ),
    regular: (
      family: (Helvetica Neue, Helvetica, Arial, sans-serif),
      weight: 400,
      style: normal
    ),
    regular-italic: (
      family: (Helvetica Neue, Helvetica, Arial, sans-serif),
      weight: 400,
      style: italic
    ),
    bold: (
      family: (Helvetica Neue, Helvetica, Arial, sans-serif),
      weight: 700,
      style: normal
    ),
    bold-italic: (
      family: (Helvetica Neue, Helvetica, Arial, sans-serif),
      weight: 700,
      style: italic
    ),
  ),

  // Serif
  georgia: (
    regular: (
      family: (Georgia, Times, Times New Roman, serif),
      weight: 400,
      style: normal
    ),
    regular-italic: (
      family: (Georgia, Times, Times New Roman, serif),
      weight: 400,
      style: italic
    ),
  )
);

To explain a bit of what is going on here, we have a Sass map (of maps of maps) called $base-font-stack. This is the only thing we will ever update to add, remove or edit a font variant. EVER. At the first depth we have maps named by the font group name (examples: helevtica, brandon, clarendon, ...). For the second depth we have maps containing the properties of each font group variants. This identifier should be unique among the group (examples: regular, bold, light-italic). Finaly, these settings maps have three keys: family, weight and style.

family: CSS font-family value. weight: CSS font-weight value. style: CSS font-style value.

And now the magic mixin:

@mixin font($group, $variant: regular, $properties: family weight style, $font-stack: $base-font-stack) {
  $font-properties: map-deep-get($font-stack, $group, $variant);
  
  @if $font-properties {
    @each $property, $values in $font-properties {
      @if contains($properties, $property) {
        font-#{$property}: map-get($font-properties, $property);
      }
    }
  }
}

Once called, the mixin will loop through $base-font-stack until it finds a match for both group and variant (default: regular), then it will output the font familly, weight and/or style.

h1 {
    @include font(helvetica, bold);
}
h1 .caption {
    @include font(helvetica, light-italic, weight style);
}
p {
    @include font(helvetica, regular);
}
p i {
    @include font(helvetica, regular-italic, style);
}
p b {
    @include font(helvetica, bold, weight);
}
blockquote {
    @include font(georgia);
}

Compiles to:

h1 {
    font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
    font-weight: 700;
    font-style: normal;
}
h1 .caption {
    font-weight: 200;
    font-style: italic;
}
p {
    font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
    font-weight: 400;
    font-style: normal;
}
p i {
    font-style: italic;
}
p b {
    font-weight: 700;
}
blockquote {
    font-family: Georgia, Times, Times New Roman, serif;
    font-weight: 400;
    font-style: normal;
}

It's easy, Isn't it? With these simple lines of code you will spare lots of times.

Workflow

First, I identifie all font variants used by our designers. It's easier if they provide you a styleguide.

Second, I convert the fonts for the web if we have the licenses for or I create kits depending on the service the fonts came from (TypeKit, Google Fonts, ...).

Third, I create the map by adding all the values ​​for the family, weight and style properties.

Go further

Basicaly to use webfonts you have to declare several @font-face. But during the development you often try out multiple fonts variants, and in the end you don't really remember which one you're actually using. The result: imported fonts that take up bandwidth and HTTP-requests, but aren't used. So! What if our smart system automaticaly declare the @font-face for all used webfonts only?

First, we add a optional font-face property to our font variant map.

$base-font-stack: (
    ...
    avenir: (
        light: (
            family: (Avenir, sans-serif),
            weight: 300,
            style: normal,
            font-face: (
                family: 'Avenir',
                path: 'Avenir/Avenir-Light',
                formats: (eot woff ttf svg)
            )
        )
    )
);

And in our font mixin we call a new track-fonts mixin which aims to list the used font variants and uniquely store them in an array.

$used-fonts: ();

@mixin track-fonts($group, $variant) {
  @if map-has-key($used-fonts, $group) == false {
    $used-fonts: map-merge($used-fonts, ($group: ())) !global;
  }
  
  $font-map: map-get($used-fonts, $group);
  @if index($font-map, $variant) == null {
    $variations: append($font-map, $variant);
    $used-fonts: map-merge($used-fonts, ($group: $variations)) !global;
  }
}

Now that we have stored the fonts we can call at the very end of or code @include import-fonts(); to add the needed @font-face rules.

@mixin import-fonts($font-stack: $base-font-stack) {
  @each $group, $variations in $used-fonts {
    @each $variant in $variations {
      $font-properties: map-deep-get($font-stack, $group, $variant);

      @if $font-properties {
        // If we have a font-face key we create the font-face rule
        $font-face: map-get($font-properties, font-face);
        @if $font-face {
          $font-family: map-get($font-face, family);
          $file-path: map-get($font-face, path);
          $file-formats: map-get($font-face, formats);
          $font-weight: map-get($font-properties, weight);
          $font-style: map-get($font-properties, style);
          
          @if $file-formats {
            @include font-face($font-family, $file-path, $font-weight, $font-style, $file-formats);
          } @else {
            @include font-face($font-family, $file-path, $font-weight, $font-style);
          }
        }
      }
    }
  }
}

Check this gist for the complete and working code https://gist.github.com/fabricelejeune/bcdd3d4725d4e4cea672

Conclusion

Here's how you win with some mixins lot of time in your workflow. It's been several months since we work with this system at Dogstudio and all developers are happy. I hope you will be too.

h1 {
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
font-weight: 700;
font-style: normal;
}
h1 .caption {
font-weight: 200;
font-style: italic;
}
p {
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
font-weight: 400;
font-style: normal;
}
p i {
font-style: italic;
}
p b {
font-weight: 700;
}
blockquote {
font-family: Georgia, Times, Times New Roman, serif;
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "Helvetica Neue";
font-style: normal;
font-weight: 700;
src: url("/public/fonts/HelveticaNeue/HelveticaNeue-Bold.eot?#iefix") format("embedded-opentype"), url("/public/fonts/HelveticaNeue/HelveticaNeue-Bold.woff") format("woff"), url("/public/fonts/HelveticaNeue/HelveticaNeue-Bold.ttf") format("truetype"), url("/public/fonts/HelveticaNeue/HelveticaNeue-Bold.svg#Helvetica Neue") format("svg");
}
@font-face {
font-family: "Helvetica Neue";
font-style: italic;
font-weight: 200;
src: url("/public/fonts/HelveticaNeue/HelveticaNeue-LightItalic.eot?#iefix") format("embedded-opentype"), url("/public/fonts/HelveticaNeue/HelveticaNeue-LightItalic.woff") format("woff"), url("/public/fonts/HelveticaNeue/HelveticaNeue-LightItalic.ttf") format("truetype"), url("/public/fonts/HelveticaNeue/HelveticaNeue-LightItalic.svg#Helvetica Neue") format("svg");
}
@font-face {
font-family: "Helvetica Neue";
font-style: normal;
font-weight: 400;
src: url("/public/fonts/HelveticaNeue/HelveticaNeue-Regular.eot?#iefix") format("embedded-opentype"), url("/public/fonts/HelveticaNeue/HelveticaNeue-Regular.woff") format("woff"), url("/public/fonts/HelveticaNeue/HelveticaNeue-Regular.ttf") format("truetype"), url("/public/fonts/HelveticaNeue/HelveticaNeue-Regular.svg#Helvetica Neue") format("svg");
}
@font-face {
font-family: "Helvetica Neue";
font-style: italic;
font-weight: 400;
src: url("/public/fonts/HelveticaNeue/HelveticaNeue-RegularItalic.eot?#iefix") format("embedded-opentype"), url("/public/fonts/HelveticaNeue/HelveticaNeue-RegularItalic.woff") format("woff"), url("/public/fonts/HelveticaNeue/HelveticaNeue-RegularItalic.ttf") format("truetype"), url("/public/fonts/HelveticaNeue/HelveticaNeue-RegularItalic.svg#Helvetica Neue") format("svg");
}
$base-url: '/public/';
$used-fonts: ();
$base-font-stack: (
// Sans-serif
helvetica: (
light: (
family: (Helvetica Neue, Helvetica, Arial, sans-serif),
weight: 200,
style: normal,
font-face: (
family: 'Helvetica Neue',
path: 'HelveticaNeue/HelveticaNeue-Light',
formats: (eot woff ttf svg)
)
),
light-italic: (
family: (Helvetica Neue, Helvetica, Arial, sans-serif),
weight: 200,
style: italic,
font-face: (
family: 'Helvetica Neue',
path: 'HelveticaNeue/HelveticaNeue-LightItalic',
formats: (eot woff ttf svg)
)
),
regular: (
family: (Helvetica Neue, Helvetica, Arial, sans-serif),
weight: 400,
style: normal,
font-face: (
family: 'Helvetica Neue',
path: 'HelveticaNeue/HelveticaNeue-Regular',
formats: (eot woff ttf svg)
)
),
regular-italic: (
family: (Helvetica Neue, Helvetica, Arial, sans-serif),
weight: 400,
style: italic,
font-face: (
family: 'Helvetica Neue',
path: 'HelveticaNeue/HelveticaNeue-RegularItalic',
formats: (eot woff ttf svg)
)
),
bold: (
family: (Helvetica Neue, Helvetica, Arial, sans-serif),
weight: 700,
style: normal,
font-face: (
family: 'Helvetica Neue',
path: 'HelveticaNeue/HelveticaNeue-Bold',
formats: (eot woff ttf svg)
)
),
bold-italic: (
family: (Helvetica Neue, Helvetica, Arial, sans-serif),
weight: 700,
style: italic,
font-face: (
family: 'Helvetica Neue',
path: 'HelveticaNeue/HelveticaNeue-BoldItalic',
formats: (eot woff ttf svg)
)
),
),
// Serif
georgia: (
regular: (
family: (Georgia, Times, Times New Roman, serif),
weight: 400,
style: normal
),
regular-italic: (
family: (Georgia, Times, Times New Roman, serif),
weight: 400,
style: italic
),
),
);
/// Fetch nested keys
///
/// @param {Map} $map - Map
/// @param {Arglist} $keys - Keys to fetch
///
/// @return {*}
@function map-deep-get($map, $keys...) {
@each $key in $keys {
@if type-of($map) != "map" {
@warn '`#{$map}` is not a map.';
@return false;
}
$map: map-get($map, $key);
}
@return $map;
}
/// Checks if a list contains a value(s).
///
/// @param {List} $list - The list to check against.
/// @param {List} $values - A single value or list of values to check for.
///
/// @example
/// contains($list, $value)
///
/// @return {Bool}
@function contains($list, $values...) {
@each $value in $values {
@if type-of(index($list, $value)) != 'number' {
@return false;
}
}
@return true;
}
/// Returns URL to a font based on its path
///
/// @param {String} $path - font path
/// @param {String} $base [$base-url] - base URL
/// @return {Url}
/// @require $base-url
@function font($path, $base: $base-url) {
@return url($base + 'fonts/' + $path);
}
/// Font styling shorthand
///
/// @param {String} $group
/// @param {String} $variant
/// @param {Map} $properties
/// @param {Map} $font-stack (optional)
///
/// @example
/// @include font(helvetica, bold);
///
/// @requires {function} font-properties
@mixin font($group, $variant: regular, $properties: family weight style, $font-stack: $base-font-stack){
$font-properties: map-deep-get($font-stack, $group, $variant);
@if $font-properties {
@include track-fonts($group, $variant);
@each $key, $value in $font-properties {
@if contains($properties, $key) {
font-#{$key}: $value;
}
}
}
}
/// Track all fonts and variations used in the stylesheet
/// Check if this combination already exists in the map.
/// If not, we add it to the map.
///
/// @param {String} $group
/// @param {String} $variant
///
/// @requires {Map} used-fonts
@mixin track-fonts($group, $variant) {
// First check if we already knew this one:
@if map-has-key($used-fonts, $group) == false {
// Font-family isn't in the map yet, so add it.
// The key for the nested map is the font name:
$used-fonts: map-merge($used-fonts, ($group: ())) !global;
}
// Now check if the variation is known
$font-map: map-get($used-fonts, $group);
@if index($font-map, $variant) == null {
// Variation isn't in the map yet, so add it:
$variations: append($font-map, $variant);
$used-fonts: map-merge($used-fonts, ($group: $variations)) !global;
}
}
/// Add the font-face rules for all used fonts
/// @return {String} font-face rules
///
/// @requires {function} map-deep-get
/// @requires {function} font-face
@mixin import-fonts($font-stack: $base-font-stack) {
@each $group, $variations in $used-fonts {
@each $variant in $variations {
$font-properties: map-deep-get($font-stack, $group, $variant);
@if $font-properties {
// If we have a font-face key we create the font-face rule
$font-face: map-get($font-properties, font-face);
@if $font-face {
$font-family: map-get($font-face, family);
$file-path: map-get($font-face, path);
$file-formats: map-get($font-face, formats);
$font-weight: map-get($font-properties, weight);
$font-style: map-get($font-properties, style);
@if $file-formats {
@include font-face($font-family, $file-path, $font-weight, $font-style, $file-formats);
} @else {
@include font-face($font-family, $file-path, $font-weight, $font-style);
}
}
}
}
}
}
/// Add a font-face rule
/// @return {String} font-face rule
///
/// @requires {function} font-source-declaration
@mixin font-face($font-family, $file-path, $font-weight: 400, $font-style: normal, $file-formats: eot woff2 woff ttf svg) {
@font-face {
font-family: $font-family;
font-style: $font-style;
font-weight: $font-weight;
src: font-source-declaration($font-family, $file-path, $file-formats);
}
}
/// Used for creating the source string for fonts using @font-face
/// Reference: http://goo.gl/Ru1bKP
///
/// @requires {function} font
@function font-source-declaration($font-family, $file-path, $file-formats) {
$src: ();
$formats-map: (
eot: '#{$file-path}.eot?#iefix' format('embedded-opentype'),
woff2: '#{$file-path}.woff2' format('woff2'),
woff: '#{$file-path}.woff' format('woff'),
ttf: '#{$file-path}.ttf' format('truetype'),
svg: '#{$file-path}.svg##{$font-family}' format('svg')
);
@each $key, $values in $formats-map {
@if contains($file-formats, $key) {
$file-path: nth($values, 1);
$font-format: nth($values, 2);
$src: append($src, font($file-path) $font-format, comma);
}
}
@return $src;
}
/// CSS declaration
h1 {
@include font(helvetica, bold);
}
h1 .caption {
@include font(helvetica, light-italic, weight style);
}
p {
@include font(helvetica, regular);
}
p i {
@include font(helvetica, regular-italic, style);
}
p b {
@include font(helvetica, bold, weight);
}
blockquote {
@include font(georgia);
}
/// Import fonts
@include import-fonts();
@mwailes11
Copy link

Having trouble getting the basic version of this to work. Keep getting "not a valid CSS value" error.

@colorincode
Copy link

colorincode commented Apr 22, 2019

i would like to add an error handling snippet to detect the compile/page load time on this - my concern is that im going to attempt to use this with typekit and the page load time

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment