Structuring a Sass project for modularity, inheritance, and customization

January 3, 2024

Structuring a Sass project for modularity, inheritance, and customization

January 3, 2024

Whether developing a front end for Drupal or Nuxt, or any other web application, I've refined a standard structure for Sass projects that can be reused, extended, and customized without too many overrides or competing implementation approaches. I also rely a few key mixins and other tools to ensure that the styles can be used both during preprocessing (Sass variables) and post-processing (CSS custom properties).

The standard structure

You can find all kinds of recommended "architectures", but I generally like to keep to a pretty simple folder structure and number of files. The complexity of the project, however, may increase if we're dealing with a CMS like Drupal that likes to stuff a ton of its own extra markup and class names into the DOM. But assuming we have some control over the HTML—either because we're defining it ourselves or are comfortable using whatever tools/mechanisms are available to us to overwrite the standard markup—I find that more complexity = spending too much time figuring out where a particular style declaration should go that makes the most logical sense (when it really doesn't matter). Also, I prefer to use the .scss syntax for Sass, because it looks more like css (brackets rather than just indents), but all of this is translatable between either (.scss or .sass).

Here's what my projects tend to start with:

sass/

  abstracts/
    _functions.scss
    _generators.scss
    _mixins.scss
    _variables.scss

  elements/
    _base.scss
    _buttons.scss
    _forms.scss
    _misc.scss
    ...

  structure/
    _header.scss
    _main.scss
    _menu.scss
    _scaffold.scss
    ...

  components/
    about.scss
    hero.scss
    shared.scss
    ...

  _init.scss
  print.scss
  all.scss

The abstracts/ directory and _init.scss

The abstracts/ directory contains Sass resources that we forward for use in our other .scss files by including the _init.scss file first. So, for example, a file like about.scss, which would contain styles specific to an "about" page/component, will include at its very top the following line:

about.scss
@use "../init" as *;

Then, the remaining code in that file can access the functions, generators, mixins, and variables in the abstracts/ directory because the _init.scss code looks like this:

_init.scss
@forward "abstracts/variables";
@forward "abstracts/functions";
@forward "abstracts/mixins";
@forward "abstracts/generators";

Variables

Variables need to be separate so that they can be used individually in the other "abstracts" files, but you could combine the other three into a single file if you want. I'd recommend including only Sass variables/maps in the _variables.scss file, and using a "generator" (see below) to convert variables into CSS custom properties where and how desired. You'll want to define a set of key variables/maps such as $colors, $breakpoints, and $font-families. For example:

abstracts/_variables.scss
$colors: (
  "white": #ffffff,
  "gray": #a9a9a9,
  "black": #000000,
  "text": #1f1f1f,
  "navy": #012039,
  "orange": #ff9f1c,
  "red": #e71d36,
  "blue": #2e9bc4,
  "pink": #d43778,
);

$breakpoints: (
  "xsmall": 320px,
  "small": 640px,
  "medium": 1024px,
  "large": 1200px,
  "xlarge": 1400px,
  "xxlarge": 1920px,
);

$font-families: (
  "display": '"Helvetica", "Arial", sans-serif',
  "serif": '"Times New Roman", sans-serif',
  "sans-serif": '"Helvetica", "Arial", sans-serif',
  "monospace": '"Consolas", "Courier New", monospace',
);

When combined with the generate-fonts function defined in the _generators.scss file, though , we can add more complex combinations/variables. So we can define another map in our _variables.scss file called $fonts, place copies of the necessary font files at the file locations defined and with the various extentions needed (eot, svg, ttf, woff, woff2), so we can then use them in our original $font-families map:

abstracts/_variables.scss
...

$fonts: (
  "Roboto Mono Regular": (
    "family": "Roboto Mono",
    "weight": "400",
    "style": "normal",
    "file": "/assets/fonts/roboto-mono/roboto-mono-regular",
  ),
  "Roboto Mono Bold": (
    "family": "Roboto Mono",
    "weight": "700",
    "style": "normal",
    "file": "/assets/fonts/roboto-mono/roboto-mono-bold",
  ),
);

...

$font-families: (
  ...

  "monospace": '"Roboto Mono Bold", "Consolas", "Courier New", monospace',
);

Generators

The generators are technically mixins, but we only use them once in the all.scss file in the Sass project root (see further detail/elaboration below). We call the generators before any other processing so that the results can be used/referenced in other parts of the project. I like to use three key generators (two of which have already been mentioned):

  1. Convert Sass variables/maps to CSS custom properties (mentioned earlier).
  2. Create variations on the original $colors variable/map for use in Sass, CSS, and in markup (i.e., as classes).
  3. Produce the @font-face rules for custom font importing (mentioned earlier).

The resulting file looks like this (note the inclusion of some specific Sass modules at the top):

abstracts/_generators.scss
@use "sass:color";
@use "sass:math";
@use "sass:meta";

@use "variables";

@mixin generate-custom-properties($variables-map, $prefix, $key: "") {
  @each $name, $value in $variables-map {
    $key-copy: $key;
    $key: #{$key}-#{$name};

    @if meta.type-of($value) == "map" {
      @include generate-custom-properties($value, $prefix, $key);
    } @else {
      :root {
        --#{$prefix}#{$key}: #{$value};
      }
    }
    $key: $key-copy;
  }
}

@mixin generate-colors($colors-map) {
  @each $name, $color in $colors-map {
    :root {
      $color-lightest: scale-color($color, $lightness: 92%);
      $color-lighter: scale-color($color, $lightness: 65%);
      $color-light: scale-color($color, $lightness: 45%);
      $color-lightish: scale-color($color, $lightness: 25%);
      $color-darkish: scale-color($color, $lightness: -15%);
      $color-dark: scale-color($color, $lightness: -30%);
      $color-darker: scale-color($color, $lightness: -50%);
      $color-darkest: scale-color($color, $lightness: -75%);
      --color-#{$name}: #{$color};
      --color-#{$name}-lightest: #{$color-lightest};
      --color-#{$name}-lighter: #{$color-lighter};
      --color-#{$name}-light: #{$color-light};
      --color-#{$name}-lightish: #{$color-lightish};
      --color-#{$name}-darkish: #{$color-darkish};
      --color-#{$name}-dark: #{$color-dark};
      --color-#{$name}-darker: #{$color-darker};
      --color-#{$name}-darkest: #{$color-darkest};
    }
    .text-color--#{$name} {
      color: var(--color-#{$name});
    }
    .background-color--#{$name} {
      background-color: var(--color-#{$name});
    }
    .hover-text-color--#{$name} {
      &:hover {
        color: var(--color-#{$name}) !important;
      }
    }
    .hover-background-color--#{$name} {
      &:hover {
        background-color: var(--color-#{$name}) !important;
      }
    }
  }
}

@mixin generate-fonts($fonts-map) {
  @each $name, $value in $fonts-map {
    @font-face {
      font-family: '#{map-get($value, "family")}';
      src: url('#{map-get($value, "file")}.eot');
      src:
        url('#{map-get($value, "file")}.eot?#iefix') format("embedded-opentype"),
        url('#{map-get($value, "file")}.woff') format("woff"),
        url('#{map-get($value, "file")}.ttf') format("truetype"),
        url('#{map-get($value, "file")}.svg?#webfont') format("svg");
      font-weight: #{map-get($value, "weight")};
      font-style: #{map-get($value, "style")};
    }
  }
}

Mixins and functions

I like to define a number of mixins/functions that either I've written/refined or that I've found in other projects. There are some really great sources out there, too. And because these are just modular functions you don't need to install/integrate a complete library (despite what some of them would have you believe!): https://github.com/thoughtbot/bourbon, https://github.com/Famolus/awesome-sass, https://gerillass.com/. For breakpoints, though, I keep to just three and never deviate or change them:

abstracts/_mixins.scss
@use "variables" as *;

@mixin above($breakpoint) {
  @if map-has-key($breakpoints, $breakpoint) {
    $breakpoint-value: map-get($breakpoints, $breakpoint);
    @media (min-width: $breakpoint-value) {
      @content;
    }
  } @else {
    @warn 'Invalid breakpoint: #{$breakpoint}.';
  }
}

@mixin below($breakpoint) {
  @if map-has-key($breakpoints, $breakpoint) {
    $breakpoint-value: map-get($breakpoints, $breakpoint);
    @media (max-width: ($breakpoint-value - 1)) {
      @content;
    }
  } @else {
    @warn 'Invalid breakpoint: #{$breakpoint}.';
  }
}

@mixin between($lower, $upper) {
  @if map-has-key($breakpoints, $lower) and map-has-key($breakpoints, $upper) {
    $lower-breakpoint: map-get($breakpoints, $lower);
    $upper-breakpoint: map-get($breakpoints, $upper);
    @media (min-width: $lower-breakpoint) and (max-width: ($upper-breakpoint - 1)) {
      @content;
    }
  } @else {
    @if (map-has-key($breakpoints, $lower) == false) {
      @warn 'Invalid breakpoint (lower): #{$lower}.';
    }
    @if (map-has-key($breakpoints, $upper) == false) {
      @warn 'Invalid breakpoint (upper): #{$upper}.';
    }
  }
}

Base styles

To keep things simple, I just include a single _base.scss partial in the elements/ direcrory where I define some of the basic html/body, typography stuff (other systems can be much more elaborate). Remember that in these and all other Sass files (partials or otherwise) we need to include our _init.scss resources:

elements/_base.scss
@use "../init" as *;

html,
body {
  line-height: var(--line-height-base);
  font-style: normal;
  font-size: var(--font-size-base-px);
  font-family: var(--font-family-sans-serif);
  font-weight: var(--font-weight-base);
  color: var(--color-text);
}

p {
  line-height: var(--line-height-base);
  font-size: var(--font-size-base);
  font-family: var(--font-family-sans-serif);
  margin: var(--margin-p);
  color: var(--color-text);

  &:last-of-type {
    margin-bottom: 0;
  }
}

...

Other partials and standalone Sass files

Elements

In that same folder (elements/) I then try to break out closely-related interface parts into additional partials files that are somewhat intuitive so that maintenance is easier and we don't end up with an unwieldy number of files. As shown in the original structure, these might include a _buttons.scss file:

elements/_buttons.scss
@use "../init" as *;

button,
a.button {
  color: var(--color-white);
  font-size: var(--font-size-base);
  font-weight: var(--font-weight-strong);
  background-color: var(--color-pink);
  padding: 1.35rem 1.75rem;

  &:hover,
  &:active,
  &:focus {
    color: var(--color-white);
    background-color: var(--color-pink-lightish);
  }

  &.skip-to-final-code {
    align-items: center;
    display: flex;

    &:before {
      content: "";
      background: url("/icons/code-icon.svg") no-repeat center/contain;
      width: 1.55rem;
      height: 1.55rem;
      display: inline-block;
    }
  }
}

Structure

And, finally, the last set of partials that we include on every page are those in the structure/ directory. These tend to be macro-level / high-up-the-DOM-tree styles that help to position layouts, provide grids and other sizing classes, layout menus, and handle overall responsiveness for columns, images, etc. For example, the _columns.scss partial might include the following:

structure/_columns.scss
@use "../init" as *;

:root {
  $column-gap: 0.75rem;
}

.columns {
  display: flex;
  flex-direction: column-reverse;
  margin-top: (-$column-gap);

  &:last-child {
    margin-bottom: (-$column-gap);
  }

  &:not(:last-child) {
    margin-bottom: calc(1.5rem - #{$column-gap});
  }

  @include above("small") {
    flex-direction: row;
  }

  &--centered {
    justify-content: center;
  }

  &--gapless {
    margin-top: 0;

    & > .column {
      margin: 0;
      padding: 0 !important;
    }
    &:not(:last-child) {
      margin-bottom: 1.5rem;
    }
    &:last-child {
      margin-bottom: 0;
    }
  }

  &--mobile {
    display: flex;
  }

  &--multiline {
    flex-wrap: wrap;
  }

  &--vcentered {
    align-items: center;
  }
}

Components

The "components" directory, I reserve for standalone Sass files (i.e., not 'partials') that we optionally include on a particular page to keep the size of the CSS assets low. I tend to use "components" to mean pages or larger modular components—and in Drupal, specifically, these will tie into specific Twig templates that handle the markup for paragraphs entities... more to come on that in a separate post (@TODO).

Pulling it "all" together

As I alluded to earlier, we then combine the generators with the partials importers in an all.scss file in the root of the Sass project:

all.scss
@use "init" as *;

@include generate-colors($colors);
@include generate-fonts($fonts);
@include generate-custom-properties($breakpoints, "breakpoint");
@include generate-custom-properties($font-families, "font-family");

@import "elements/*";
@import "structure/*";

With these pieces in place, we can extend the basic implementation to include, say, additional column classes that respond to different breakpoints. First we add a Sass variable/map, then run it through the custom properties generator, and finally append the needed style declarations to the existing columns file:

abstracts/_variables.scss
...

$column-widths: (
  "full": 100%,
  "three-quarters": 75%,
  "two-thirds": 66.6667%,
  "half": 50%,
  "one-third": 33.3333%,
  "one-quarter": 25%,
  "one-fifth": 20%,
  "two-fifths": 40%,
  "three-fifths": 60%,
  "four-fifths": 80%,
);

...
all.scss
...

@include generate-custom-properties($column-widths, "column-width");

...
structure/_columns.scss
...


@include above("medium") {
  .columns--2col-25-75 {
    & > .column:nth-of-type(1) {
      width: var(--column-width-one-quarter);
    }
    & > .column:nth-of-type(2) {
      width: var(--column-width-three-quarters);
    }
  }
  .columns--2col-33-67 {
    & > .column:nth-of-type(1) {
      width: var(--column-width-one-third);
    }
    & > .column:nth-of-type(2) {
      width: var(--column-width-two-thirds);
    }
  }

...

}

...