How to make a Bootstrap 5 theme: customise utility classes

Now that our typography setup is done, we can indulge in customising some Bootstrap utility classes.

The scope of this tutorial is to learn and start using the utility class API generator Bootstrap 5 provides.

The general idea

The idea behind utility classes is to have an immutable, composable, predictable set of css classes to avoid side-effects as much as possible.

Bootstrap — like other css frameworks — provides a set of utility classes out of the box, but allows you to add, remove and change them as you need.

The main files related to the utility classes are:

In a way to customise the utility classes, we’ll make a copy of the scss/_utilities.scss and import it in our styles.scss.

// Loading flow

1. import bootstrap/scss/mixins/_utilities.scss 
   this will make the generate-utility mixin available

2. import bootstrap/scss/_utilities.scss
   this will create the $utilities map 

3. import bootstrap/scss/utilities/_api.scss
   which uses the $utilities map and generate-utility to actually
   generate the css classes

Sass maps

Before we investigate further, we have to explain a little about sass maps so you can make sense of the Bootstrap files involved.

Map basics:

// Scss basic syntax
$font-weights: ("regular": 400, "medium": 500, "bold": 700);

// Just to give some mental model this would be the 
// the same map if it was written in javascript (as object)
var fontWeights = {"regular": 400, "medium": 500, "bold": 700};

Iterate on a map:

// Assuming the following map
$font-weights: ("regular": 400, "medium": 500, "bold": 700);

// Iterate on the map
@each $name, $value in $font-weights {
    .fw-#{$name} {
       font-weight: $value;
    }
}

// Which will generate
.fw-regular {
    font-weight: 400;
}

.fw-medium {
    font-weight: 500;
}

.fw-bold {
    font-weight: 700;
}

Join two maps:

$font-weights-base: ("regular": 400, "medium": 500, "bold": 700);
$font-weights-exte: ("lighter": 100, "bolder": 900);

$font-weights: map-merge($font-weights-base, $font-weights-exte);

// $font-weights content will be 
("regular": 400, "medium": 500, "bold": 700, "lighter": 100, "bolder": 900);

Get a value for a specific key:

$font-weights: ("regular": 400, "medium": 500, "bold": 700);
map-get($font-weights, "medium"); // 500

Set a value for a specific key:

$font-weights: ("regular": 400, "medium": 500, "bold": 700);
map-set($font-weights, "yadda", 20);
// $font-weights content will be 
// ("regular": 400, "medium": 500, "bold": 700, "yadda": 20);

Case node-sass vs dart-sass

While originally built with ruby, sass has been for a long time developed in C/C++ (LibSass and node-sass) but it is now moving to Dart (dart-sass).

As the writing of this tutorial, LibSass and node-sass are deprecated.

With dart-sass though a new module system is added and for this reason, some newer syntax in the official docs may need the map module imported to work.

E.g.: map-get vs map.get

// previous example with the `.` syntax
@use "sass:map";
map.get($font-weights, "medium"); 

See: “Sass map.get() doesn’t work. map-get() does. What gives?”.

Basic setup

There are several approaches we could use to customize our utility classes.

Approaches to customize utility classes:

  1. extend $utilities after our variables
  2. copy the whole node_modules/bootstrap/scss/_utilities.scss to our scss folder

Extend $utilities

As the variable $utilities defined in node_modules/bootstrap/scss/_utilities.scss has a !default keyword we could potentially create a new $utilities before importing node_modules/bootstrap/scss/_utilities.scss and that would be merged with the rest of the values.

Another way would be using map-merge after @import "bootstrap/scss/utilities";.

In both scenarios, we try to alter the default map.

Despite being the methodology proposed in the Bootstrap 5 documentation I think there may be some inconvenience.

Firstly, you can not read all the utility classes at once, as part of the definition would stay in node_modules/bootstrap/scss/mixins/_utilities.scss and the other part where we use the map-merge.

Secondly, if you have to modify or remove some utility the experience can be a little bit unpleasant as you may need to use a combination of map-merge and map-get.

Copy node_modules/bootstrap/scss/_utilities.scss

For these reasons, we’ll copy node_modules/bootstrap/scss/_utilities.scss instead to our scss folder and start editing directly so we’ll have all the utilities in one place and no map-merge required.

The drawback of this approach is that in case of a breaking change a mere npm update would not suffice as we’ll need to re-sync _utilities.scss with the latest Bootstrap version.

Assuming minor changes (in the semver sense) in theory the data structure should only expand leaving our code working but possibly without the latest feature.

Drawback Example:

In Bootstrap 5.1.3

https://github.com/twbs/bootstrap/blob/v5.1.3/scss/_utilities.scss#L138

"border-color": (
  property: border-color,
  class: border,
  values: map-merge($theme-colors, ("white": $white))
),

In Bootstrap 5.2.0

https://github.com/twbs/bootstrap/blob/v5.2.0/scss/_utilities.scss#L136

"border-color": (
  property: border-color,
  class: border,
  local-vars: (
    "border-opacity": 1
  ),
  values: $utilities-border-colors
),

In this case, except we manually update `border-color` we would loose
local-vars and the new maps in values.

Considering all of that, I think is still a reasonable tradeoff to make a copy because I’m assuming I’ll spend more time on writing utilities than updating Bootstrap. In addition, forcing myself to do the manual update may inspire new changes for other customised utilities.

cp node_modules/bootstrap/scss/_utilities.scss scss/_utilities.scss

At the end of this commands you should have:

bootstrap5-theme
├── node_modules
├── .nvmrc
├── .gitignore
├── package-lock.json
├── package.json
└── scss
    ├── _utilities.scss
    ├── _variables.scss
    └── styles.scss

and then in our styles.scss.

Change from 
@import "./node_modules/bootstrap/scss/utilities";

To
@import "utilities";

You should have:

// Configuration
@import "./node_modules/bootstrap/scss/functions";
@import "variables";
@import "./node_modules/bootstrap/scss/mixins";
@import "utilities";

// Layout & components
@import "./node_modules/bootstrap/scss/root";
@import "./node_modules/bootstrap/scss/reboot";
@import "./node_modules/bootstrap/scss/type";
@import "./node_modules/bootstrap/scss/images";
@import "./node_modules/bootstrap/scss/containers";
@import "./node_modules/bootstrap/scss/grid";
@import "./node_modules/bootstrap/scss/tables";
@import "./node_modules/bootstrap/scss/forms";

$utilities in scss/_utilities

So, as said, $utilities is a map and it is used in combination with the generate-utility mixin to generate the csss classes.

Let’s take in consideration the following:

$utilities: () !default;
$utilities: map-merge(
    (
        
        "opacity": (
            property: opacity,
            values: (
                0: 0,
                25: .25,
                50: .5,
                75: .75,
                100: 1,
            )
        ),
        
        .
        .
        .
        
        
        "display": (
            responsive: true,
            print: true,
            property: display,
            class: d,
            values: inline inline-block block grid table table-row table-cell flex inline-flex none
        )
    ),
    $utilities
);

As key we have the name of the css property. In this case "opacity" and "display".

Reading at _api.scss I can not see anywhere the key is used. Thus, I would conclude that the key would be merely used in case of override and in case map-get is needed as shown in the official documentation.

The value of the property is another map. "opacity" has only property and values, while "display" has responsive, print, property, class and values.

How is the class name composed within generate-utility?

If we check mixins/_utilities.scss#L64 we can see that the class name is composed by .#{$property-class + $infix + $property-class-modifier}.

// assuming opacity-md-50
.#{$property-class + $infix + $property-class-modifier}

        ^              ^                  ^
        |              |                  |

     opacity          md                  50  

$property-class

$property-class will use the class property if present, else it will use the property key.

// https://github.com/twbs/bootstrap/blob/v5.1.3/scss/mixins/_utilities.scss#L20
// Use custom class if present
$property-class: if(map-has-key($utility, class), map-get($utility, class), nth($properties, 1));
$property-class: if($property-class == null, "", $property-class);

$property-class examples

E.g.  "opacity"

all the classes will be `.opacity-0, .opacity-25 ...` because it has only the `property` key.

"opacity": (
    property: opacity, <<<< IT WILL BE USED
    values: (
        0: 0,
        25: .25,
        50: .5,
        75: .75,
        100: 1,
    )
),

E.g. "display"
all the classes will be `.d-inline, .d-block ...` because it has the `class` key.

"display": (
    responsive: true,
    print: true,
    property: display,
    class: d,  <<<< IT WILL BE USED
    values: inline inline-block block grid table table-row table-cell flex inline-flex none
)

$property-class-modifier

For $property-class-modifier, if values is a list will use directly, if values is a map it will use the key.

// https://github.com/twbs/bootstrap/blob/v5.1.3/scss/mixins/_utilities.scss#L64
// get the values
$values: map-get($utility, values);
 
// If the values are a list or string, convert it into a map
@if type-of($values) == "string" or type-of(nth($values, 1)) != "list" {
    $values: zip($values, $values);
}

.
.
.


@each $key, $value in $values {

    .
    .
    .


    // Don't prefix if value key is null (eg. with shadow class)
    $property-class-modifier: if($key, if($property-class == "" and $infix == "", "", "-") + $key, "");

    .
    .
    .


    .#{$property-class + $infix + $property-class-modifier} {
      @each $property in $properties {
        @if $is-local-vars {
          @each $local-var, $value in $is-local-vars {
            --#{$variable-prefix}#{$local-var}: #{$value};
          }
        }
        #{$property}: $value if($enable-important-utilities, !important, null);
      }
    }
}

$property-class-modifier examples

E.g.  "opacity"

`values` is a map. The map `keys` are `0, 25, 50, 75, 100`, while the map `values` are `0, .25, .5, .75, 1`

"opacity": (
    property: opacity, <<<< IT WILL BE USED
    values: (
        0: 0,
        25: .25,
        50: .5,
        75: .75,
        100: 1,
    )
),


.opacity-0 {
    opacity: 0;
}

.opacity-25 {
    opacity: .25;
}

.opacity-50 {
    opacity: .5;
}

.
.
.

E.g. "display"
`values` is a list. it will be used to generate $property-class-modifier and $value

"display": (
    responsive: true,
    print: true,
    property: display,
    class: d,  <<<< IT WILL BE USED
    values: inline inline-block block grid table table-row table-cell flex inline-flex none
)

.d-inline {
    display: inline;
}

.d-inline-block {
    display: inline-block;
}

.
.
.

$infix, print and responsive

As per utilities/_api.scss#L12 when responsive it uses @include media-breakpoint-up and injects xs, sm, md, lg, xl, xxl as defined in _variables.scss as $infix to generate the responsive classes.

This will generate classes like .d-md-none, .d-lg-none, .d-xl-none and so on.

While for the print key, as per utilities/_api.scss#L39, @media print is used with a print infix.

This will generate classes like .d-print-none, .d-print-inline, .d-print-block.

Let’s add our first utility

So, we’re ready to add our first utility. We’ll add some classes to control the z-index property.

The classes will be .z- with a value from 0 to 5.

// in our local _utilities.scss 
// let's add

"z-index": (
     property: z-index,
     class: z,
     values: 0 1 2 3 4 5
),

so, now you should have

  1 // stylelint-disable indentation
  2
  3 // Utilities
  4
  5 $utilities: () !default;
  6 // stylelint-disable-next-line scss/dollar-variable-default
  7 $utilities: map-merge(
  8   (
  9     "z-index": (
 10         property: z-index,
 11         class: z,
 12         values: 0 1 2 3 4 5
 13     ),
 14     // scss-docs-start utils-vertical-align
 15     "align": (
 16       property: vertical-align,
 17       class: align,
 18       values: baseline top middle bottom text-bottom text-top
 19     ),
 20     // scss-docs-end utils-vertical-align
 21     // scss-docs-start utils-float
 22     "float": (
 23       responsive: true,
 24       property: float,
 25       values: (
 26         start: left,
 27         end: right,
 28         none: none,
 29       )
 30     ),
  .
  .
  .
  .

if now you do a npm run build you should have in css/styles.css.

.z-0 {
  z-index: 0 !important; }
.z-1 {
  z-index: 1 !important; }
.z-2 {
  z-index: 2 !important; }
.z-3 {
  z-index: 3 !important; }
.z-4 {
  z-index: 4 !important; }
.z-5 {
  z-index: 5 !important; }

Quick recap:

  1. we did explore sass maps
  2. we copied the _utilities.scss file in local
  3. we explored the structure of the $utilities, in particular property, values, class and responsive
  4. we created a new utility for z-index

Of particular importance, we saw the structure of $utilities and how the css class name is formed from the values in property, values, class and responsive.

With this basic knowledge of the Bootstrap 5 utility API, you should be comfortable in adding new utilities and exploring other options available as rfs, rtl, css-var the API support.