Wick Technology Blog

Changing Bootstrap Theme at Runtime with CSS Variables

July 03, 2020

Bootstrap is a well-known framework for quickly building websites and apps. The way to customise it is well documented - override the SASS variables which are provided. This allows customisation at compile-time - if you know your colours and branding when you develop the app. However, what if you want to change the theme of your Bootstrap components at runtime? For example, maybe you want to allow choice between a light or dark theme, or, as in my case, you have multiple tenants visiting your site, each with their own branding.

The Requirements

The first requirement is that the tenant branding should be stored in the app database, so it can be changed easily. Secondly I don’t want to have to redeploy the app when a new tenant is added. So that excludes adding a new a CSS file for every tenant. Dynamic theming is possible with CSS custom properties (CSS variables). You can change the values of the CSS variables in Javascript, and they will apply to the browser immediately. So, is this possible in Bootstrap?

CSS Variables Solution

I stumbled across the possibility of dynamic theming in an issue thread.

Top tip, don’t just search google for blog posts when you need ideas. Search issues in Github for the library you’re using and see if it’s been solved, answered or has a workaround.

The problem with using CSS variables in Bootstrap is that all the SASS colour functions require a colour type input - they can’t handle a string like var(--primary).

On this issue, the idea of using CSS variables to change the Bootstrap theme had been dismissed as too much work in the past but had just been reopened. A contributor to the project, johanlef, posted an idea for how to override the SASS functions to enable using hsl values assigned to CSS variables which could then be assigned to SASS variables.

Drawbacks

This way of dynamic theming uses the CSS function calc() which isn’t compatible on IE11.

How I implemented it

Firstly, take Johan’s SASS functions and put them in a file called _functions-override.scss.

Secondly, in _bootstrap-variables.scss, set your Bootstrap SASS variables to reference CSS variables:

$primary: var(--primary);

$theme-colors: (
  'primary': var(--primary)
);

$primary now references the string var(--primary), which can be set at runtime.

Thirdly, change the order of imports in your main SASS file:

// functions and mixins first
@import '~bootstrap/scss/functions';
@import '~bootstrap/scss/mixins';

// override bootstrap functions to comply with --vars
@import 'functions-override';

// Override Boostrap variables
@import 'bootstrap-variables';
// add other themes if you want
@import '~bootswatch/dist/sandstone/variables';
// Import Bootstrap source files from node_modules
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/root";
@import "~bootstrap/scss/reboot";
@import "~bootstrap/scss/type";
@import "~bootstrap/scss/images";
@import "~bootstrap/scss/code";
@import "~bootstrap/scss/grid";
@import "~bootstrap/scss/tables";
@import "~bootstrap/scss/forms";
@import "~bootstrap/scss/buttons";
@import "~bootstrap/scss/transitions";
@import "~bootstrap/scss/dropdown";
@import "~bootstrap/scss/button-group";
@import "~bootstrap/scss/input-group";
@import "~bootstrap/scss/custom-forms";
@import "~bootstrap/scss/nav";
@import "~bootstrap/scss/navbar";
@import "~bootstrap/scss/card";
@import "~bootstrap/scss/breadcrumb";
@import "~bootstrap/scss/pagination";
@import "~bootstrap/scss/badge";
@import "~bootstrap/scss/jumbotron";
@import "~bootstrap/scss/alert";
@import "~bootstrap/scss/progress";
@import "~bootstrap/scss/media";
@import "~bootstrap/scss/list-group";
@import "~bootstrap/scss/close";
@import "~bootstrap/scss/toasts";
@import "~bootstrap/scss/modal";
@import "~bootstrap/scss/tooltip";
@import "~bootstrap/scss/popover";
@import "~bootstrap/scss/carousel";
@import "~bootstrap/scss/spinners";
@import "~bootstrap/scss/utilities";
@import "~bootstrap/scss/print";
//other app specific css below

I’ve included all the Bootstrap SASS files above, but you can remove those you don’t need.

Lastly set the CSS variables based on some app state. I’m using React Helmet to change the page head and set the CSS variables in an in-line style. The below code mostly uses Johan’s code from his gist, with a few tweaks for Typescript and using it with React Helmet. I get my app state from a Redux store but this could just as easily be retrieved from React Context or other state management.

import React from 'react'
import { connect } from 'react-redux';
import { IRootState } from 'app/shared/reducers';
import { Helmet } from 'react-helmet';
import identity from 'lodash/identity'
import map from 'lodash/map'
import trim from 'lodash/trim'

const printCss = (suffix = '', convert: (string) => string = identity) => {
  return (value, property) => `--${property}${suffix ? '-' + suffix : ''}: ${convert(value)};`
}

const rgbToHsl = (red, green, blue) => {
  const r = Number(trim(red)) / 255
  const g = Number(trim(green)) / 255
  const b = Number(trim(blue)) / 255

  const max = Math.max(r, g, b)
  const min = Math.min(r, g, b)
  let h,
    s,
    l = (max + min) / 2

  if (max === min) {
    h = s = 0 // achromatic
  } else {
    const d = max - min
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
    switch (max) {
      case r:
        h = (g - b) / d + (g < b ? 6 : 0)
        break
      case g:
        h = (b - r) / d + 2
        break
      case b:
        h = (r - g) / d + 4
        break
      default:
        break
    }
    h /= 6
  }

  h = Math.round(360 * h)
  s = Math.round(100 * s)
  l = Math.round(100 * l)

  return [h, s, l]
}

// from @josh3736 | https://stackoverflow.com/a/3732187
const colorToHsl = (color: string): any[] => {
  if (color.startsWith('#')) {
    if (color.length === 4) {
      const r = parseInt(color.substr(1, 1) + color.substr(1, 1), 16)
      const g = parseInt(color.substr(2, 1) + color.substr(2, 1), 16)
      const b = parseInt(color.substr(3, 1) + color.substr(3, 1), 16)
      return rgbToHsl(r, g, b)
    } else {
      const r = parseInt(color.substr(1, 2), 16)
      const g = parseInt(color.substr(3, 2), 16)
      const b = parseInt(color.substr(5, 2), 16)
      return rgbToHsl(r, g, b)
    }
  } else if (color.startsWith('rgba')) {
    const [r, g, b] = color.slice(5, -1).split(',')
    return rgbToHsl(r, g, b).slice(0, 3)
  } else if (color.startsWith('rgb')) {
    const [r, g, b] = color.slice(4, -1).split(',')
    return rgbToHsl(r, g, b)
  } else if (color.startsWith('hsla')) {
    return color.slice(5, -1).split(',').slice(0, 3)
  } else if (color.startsWith('hsl')) {
    return color.slice(4, -1).split(',')
  } else {
    // named color values are not yet supported
    console.error('Named color values are not supported in the config. Convert it manually using this chart: https://htmlcolorcodes.com/color-names/')
    return [0, 0, 16] // defaults to dark gray
  }
}

export const ApplyBranding = ({colors}) => {
  if (colors) {
    return (
      <Helmet>
        <style>
          {`:root {
          ${colors &&
          map(
            colors,
            printCss('', color => {
              const hsl = colorToHsl(color)
              return `hsl(${hsl[0]}, ${hsl[1]}%, ${hsl[2]}%)`
            })
          )}
          ${colors &&
          map(
            colors,
            printCss('h', color => {
              const hsl = colorToHsl(color)
              return hsl[0]
            })
          )}
          ${colors &&
          map(
            colors,
            printCss('s', color => {
              const hsl = colorToHsl(color)
              return `${hsl[1]}%`
            })
          )}
          ${colors &&
          map(
            colors,
            printCss('l', color => {
              const hsl = colorToHsl(color)
              return `${hsl[2]}%`
            })
          )}
          }`}
        </style>
      </Helmet>
    )
  } else return null
}

export const TenantAwareTheming = (props: StateProps) => {
  return <ApplyBranding colors={{
    primary: props.tenant.branding.primary,
    secondary: props.tenant.branding.secondary,
  }}/>
}

const mapStateToProps = ({tenant}: IRootState) => ({
  tenant: tenant.currentTenant
});

type StateProps = ReturnType<typeof mapStateToProps>;

export default connect(mapStateToProps)(TenantAwareTheming);

Conclusion

So really, most of this isn’t my work - but I wanted to draw attention to it since it took me so long to find it! Hopefully this can save time for someone else. Thank you, Johan, for producing this solution.


Phil Hardwick

Written by Phil Hardwick