Skip to content

Instantly share code, notes, and snippets.

@johanlef
Last active September 6, 2024 10:03
Show Gist options
  • Save johanlef/518a511b2b2f6b96c4f429b3af2f169a to your computer and use it in GitHub Desktop.
Save johanlef/518a511b2b2f6b96c4f429b3af2f169a to your computer and use it in GitHub Desktop.
Use CSS custom properties (--var) with bootstrap 4 (SCSS)

The file _functions-override.scss contains the custom functions to handle color conversions within sass and bootstrap.

Bootstrap does not like its sass variables set to css custom properties, e.g. var(--primary). If you use the code snippets below, you can do so, under some conditions.

In the most basic case, you should provide your color variables using the hsl format.

If you insert this using javascript, you can use the script apply-colors.jsx to let js handle the conversion from hex or rgb to hsl.

Reference the main.scss file to import the files in the correct order.

@function is-color($color) {
@if (type-of($color) == color) {
@return true;
}
@return false;
}
@function count-occurrences($string, $search) {
$searchIndex: str-index($string, $search);
$searchCount: 0;
@while $searchIndex {
$searchCount: $searchCount + 1;
$string: str-slice($string, $searchIndex + 1);
$searchIndex: str-index($string, $search);
}
@return $searchCount;
}
@function str-is-between($string, $first, $last) {
$firstCount: count-occurrences($string, $first);
$lastCount: count-occurrences($string, $last);
@return $firstCount == $lastCount;
}
@function recursive-color($color, $index: 0) {
$indices: (
0: h,
1: s,
2: l,
3: a
);
// find end of part
$end: str-index($color, ',');
@while ($end and not str-is-between(str-slice($color, 0, $end - 1), '(', ')')) {
$newEnd: str-index(str-slice($color, $end + 1), ',');
@if (not $newEnd) {
$newEnd: 0;
}
$end: 2 + $end + $newEnd;
}
@if ($end) {
$part: str-slice($color, 0, $end - 1);
$value: map-merge(
(
map-get($indices, $index): $part
),
recursive-color(str-slice($color, $end + 1), $index + 1)
);
@return $value;
}
@return ();
}
@function to-hsl($color) {
$c: inspect($color);
$h: 0;
$s: 0;
$l: 0;
$a: 1;
@if (is-color($color)) {
// std color
$h: hue($color);
$s: saturation($color);
$l: lightness($color);
$a: alpha($color);
@return (h: $h, s: $s, l: $l, a: $a);
}
@if (str-slice($c, 0, 3) == 'var') {
// var(--color)
$commaPos: str-index($c, ',');
$end: -2;
@if ($commaPos) {
$end: $commaPos - 1;
}
$var: str-slice($c, 7, $end);
$h: var(--#{$var}-h);
$s: var(--#{$var}-s);
$l: var(--#{$var}-l);
$a: var(--#{$var}-a, 1);
@return (h: $h, s: $s, l: $l, a: $a);
}
@if ($c == '0') {
@return (h: $h, s: $s, l: $l, a: $a);
}
// color is (maybe complex) calculated color
// e.g.: hsla(calc((var(--white-h) + var(--primary-h)) / 2), calc((var(--white-s) + var(--primary-s)) / 2), calc((var(--white-l) + var(--primary-l)) / 2), calc((var(--white-a, 1) + var(--primary-a, 1)) / 2)), hsla(calc((var(--white-h) + var(--primary-h)) / 2), calc((var(--white-s) + var(--primary-s)) / 2), calc((var(--white-l) + var(--primary-l)) / 2), calc((var(--white-a, 1) + var(--primary-a, 1)) / 2))
$startPos: str-index($c, '(');
$c: str-slice($c, $startPos + 1, -2); // 3 or 4 comma-separated vomplex values
@return recursive-color($c);
// $hEnd: str-index($c, ',');
// @if ($hEnd) {
// $h: str-slice($c, 0, $hEnd - 1);
// $c: str-slice($c, $hEnd + 1);
// $sEnd: str-index($c, ',');
// @if ($hEnd) {
// $h: str-slice($c, 0, $hEnd - 1);
// $c: str-slice($c, $hEnd + 1);
// $sEnd: str-index($c, ',');
// }
// }
// @return (h: $h, s: $s, l: $l, a: $a);
}
@function render-hsla($h, $s, $l, $a: 1) {
@return hsla($h, $s, $l, $a);
}
@function lighten($color, $amount) {
@if (is-color($color)) {
@return scale-color($color: $color, $lightness: $amount);
}
$c: to-hsl($color);
$h: map-get($c, h);
$s: map-get($c, s);
$l: map-get($c, l);
$a: map-get($c, a);
@return render-hsla($h, $s, calc(#{$l} + #{$amount}), $a);
}
@function darken($color, $amount) {
@return lighten($color, $amount * -1);
}
@function rgba($red, $green, $blue: false, $alpha: false) {
$color: $red;
@if (not $blue and not $alpha) {
$alpha: $green;
$color: $red;
}
$c: to-hsl($color);
$h: map-get($c, h);
$s: map-get($c, s);
$l: map-get($c, l);
@return render-hsla($h, $s, $l, $alpha);
}
@function rgb($red, $green, $blue) {
@return rgba($red, $green, $blue, 1);
}
@function mix($color-1, $color-2, $weight: 50%) {
$c1: to-hsl($color-1);
$c2: to-hsl($color-2);
$h1: map-get($c1, h);
$s1: map-get($c1, s);
$l1: map-get($c1, l);
$a1: map-get($c1, a);
$h2: map-get($c2, h);
$s2: map-get($c2, s);
$l2: map-get($c2, l);
$a2: map-get($c2, a);
$h: calc((#{$h1} + #{$h2}) / 2);
$s: calc((#{$s1} + #{$s2}) / 2);
$l: calc((#{$l1} + #{$l2}) / 2);
$a: calc((#{$a1} + #{$a2}) / 2);
@return render-hsla($h, $s, $l, $a);
}
@function fade-in($color, $amount) {
$c: to-hsl($color);
$h: map-get($c, h);
$s: map-get($c, s);
$l: map-get($c, l);
$a: map-get($c, a);
@if (not $a) {
$a: 1;
}
@return render-hsla($h, $s, $l, $a + $amount);
}
@function color-yiq($color, $dark: $yiq-text-dark, $light: $yiq-text-light) {
@if (is-color($color)) {
$r: red($color);
$g: green($color);
$b: blue($color);
$yiq: (($r * 299) + ($g * 587) + ($b * 114)) / 1000;
@if ($yiq >= $yiq-contrasted-threshold) {
@return $dark;
} @else {
@return $light;
}
} @else {
$c: to-hsl($color);
$l: map-get($c, l);
$th: $yiq-contrasted-threshold / 2.56; // convert hex to dec
$lightness: calc(-100 * calc(#{$l} - #{$th * 1%}));
// ignoring hue and saturation, just a light or dark gray
@return render-hsla(0, 0%, $lightness, 1);
}
}
// This code generates correct css custom properties
// from any color code (no named color yet)
import React from 'react'
import identity from 'lodash/identity'
import map from 'lodash/map'
import trim from 'lodash/trim'
const printCss = (suffix = '', convert = 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
}
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 => {
if (color.slice(0, 1) === '#') {
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.slice(0, 4) === 'rgba') {
const [r, g, b] = color.slice(5, -1).split(',')
return rgbToHsl(r, g, b).slice(0, 3)
} else if (color.slice(0, 3) === 'rgb') {
const [r, g, b] = color.slice(4, -1).split(',')
return rgbToHsl(r, g, b)
} else if (color.slice(0, 4) === 'hsla') {
return color.slice(5, -1).split(',').slice(0, 3)
} else if (color.slice(0, 3) === '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 (
<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>
)
} else return null
}
// application (React)
<App>
<ApplyBranding colors={{ primary: 'hsl(30, 40%, 50%)', secondary: 'rgb(192, 144, 32)', light: '#FFEEAA' }} />
{/* App components */}
</App>
<!DOCTYPE html>
<html>
<head>
<style>
:root {
/* Provide your colors in hsl format! */
--primary: hsl(30, 40%, 50%);
--primary-h: 30;
--primary-s: 40%;
--primary-l: 50%;
/* See below how to generate this with javascript from any color code! */
}
</style>
</head>
<body />
</html>
@import '~bootstrap/scss/functions';
@import '~bootstrap/scss/mixins';
// override bootstrap functions to comply with --vars
@import 'functions-override';
// define static bootstrap variables here
$border-radius: 1em;
// finally import bootstrap (or a subset)
// do not import ~bootstrap/scss/bootstrap
// because it will override our own color-yiq
@import '~bootstrap/scss/variables';
@import '~bootstrap/scss/<module>';
@dinandmentink
Copy link

dinandmentink commented Aug 12, 2021

Ah. This code was written pre-5.1, yes.

In bootstrap 5.1 more utility classes (like .bg-primary and .text-secondary) are now using css variables. I have managed to get full styling based on css variables using bootstrap 5.1 for my specific usecase by:

  • changing as much as possible (just see if it breaks) variables to reference css variables in variables.scss
  • include a theme.scss at the end of my app.scss entrypoint which overrides a few items (that are used by my app) based on css variables.

It's a bit hacky, and quite bootstrap dependent but it works as much as I need it to. I expect I will have to manually check themeing still works on bootstrap upgrades.

So for now I have solved my usecase, but will keep following this thread because a solution that doesn't require a hacky theme.scss and allows themeing everything (not just the items I bothered to included based on what I use) might be better.

I have put the gist of it here: https://gist.github.com/dinandmentink/4c3453bb3f3370c889ec84960d363237, it's very specific to the elements I need on my website, might help someone else.

Edit: I would recommend checking out bootstrap 5.1, I got it to work without the magic mixins posted above.

@jipis
Copy link

jipis commented Aug 12, 2021

Nah, no good. My variables file has, for example

$black: var(--black);
$blue: var(--blue);
$indigo: var(--indigo);
$purple: var(--purple);
$pink: var(--pink);

With the css variables being set on :root and :root.theme-x differently. Our theme switcher just changes the class at the root of the page, variables get new values, theme changed. But, it relies on css variables for runtime value changes. It's not the only way to do this, sure; but it is the road we've already headed down.

With bootstrap/scss not really making things easy with the update to bs5 (worked great for us with react-bootstrap for bs4), I'm thinking it's time to find a new solution. :-/

@R-Iqbal
Copy link

R-Iqbal commented Jan 5, 2022

Hey, can you point me in the right direction to understand exactly what primary-h, primary-s and primary-l are doing and how the values can be set?

@johanlef
Copy link
Author

johanlef commented Jan 6, 2022

@R-Iqbal primary-h, primary-s and primary-l are the hue, saturation and lightness of the color I named primary, e.g. the main color of your brand. The values can be set manually (hardcoded) as shown in the html file, or generated from javascript as seen in the jsx file where I wrote the function ApplyBranding to convert color values to css 'custom properties'.

Example:

<App>
  <ApplyBranding colors={{ primary: 'hsl(30, 40%, 50%)' }} />
  {/* you can use RGB, HSL and HEX values, no html color names */}
</App>

… will generate this style element in your html …

<style>
  :root {
    --primary: hsl(30, 40%, 50%);
    --primary-h: 30;
    --primary-s: 40%;
    --primary-l: 50%;
  }
</style>

… which is necessary to make the sass code work (to calculate the correct darken, lighten etc values).

→ So you can set the style values manually, or generate them with javascript, which was necessary in my project, since the colors were set externally, which was the main reason of creating this gist to start with.

I hope this helps?

@R-Iqbal
Copy link

R-Iqbal commented Jan 6, 2022

@johanlef Gotcha that makes a lot of sense. Is there any reason why we need to store the hue, saturation and light in their own variables? Is there a reason why they are not just derived from the call to hsl?

@johanlef
Copy link
Author

johanlef commented Jan 7, 2022

@R-Iqbal They are needed as separate css-variables (not sass-variables!) because I make use of the css calc() function. Maybe some optimisations are possible 🙂

The sass-functions above are modified to be able to accept css-custom-properties (css variables), in essence they generate css-code to calculate the new colors on runtime instead of during sass-preprocessing. This enables you to update colors programmatically in-app, injected by js or other sources like user input or custom stylesheets.

@mielp
Copy link

mielp commented Feb 4, 2022

Note for Bootstrap 4 users, if you want correctly themed alerts, list group items, and other things relying on Bootstrap's internal theme-color-level function, you will need to override that function as well. Here is my take on it, free to use:

@function theme-color-level($color-name: "primary", $level: 0) {
  $color: theme-color($color-name);
  @if ($level == 0) {
    @return $color;
  }

  $amount: $theme-color-interval * abs($level) / 100%;
  $c: to-hsl($color);
  $h: map-get($c, h);
  $s: map-get($c, s);
  $l: map-get($c, l);
  $a: map-get($c, a);

  @if ($level > 0) {
    // Darken -X%: L = L * (1 - X)
    $rl: calc((#{$l} * #{1 - $amount}));
    @return render-hsla($h, $s, $rl, $a);
  }
  @if ($level < 0) {
    // Ligthen +X%: L = L + X * (100 - L)
    $rl: calc(#{$l} + #{$amount} * (100% - #{$l}));
    @return render-hsla($h, $s, $rl, $a);
  }
}

@reshmamarla
Copy link

Thank you for the solution.
i am facing some issues while integrating, getting the below error


$c: str-slice($c, $startPos + 1, -2); // 3 or 4 comma-separated vomplex values
                     ^
 Invalid null operation: "null plus 1"

i have hardcoded the colour value in my HTML
<style> :root{ --primary: hsl(30, 40%, 50%); --primary-h: 30; --primary-s: 40%; --primary-l: 50%; } </style>

Not sure what i am missing here.Any help with this is appreciated. Thank you

@johanlef
Copy link
Author

@reshmamarla You could try to log the value of $c on which $startPos is calculated using the @debug sass function.

Your error was reported before, but people seemed to have found a way around it isolate a problem outside my code.

@rafeehcp
Copy link

@reshmamarla did you resolve the issue?

@rafeehcp
Copy link

Turns out that the function was working fine but some sass functions needed some tweaking and there were a plethora of bad sass variables in the vendor scss files that I was including. Wow!! Thanks again!

@danwalker-caci Could you please describe the changes you made?

@Tristan10
Copy link

Tristan10 commented Dec 7, 2022

Fairly old thread, but did anyone have or solve issues related to the color-yiq function in functions-override.scss ? That function seems to be the root cause of the button shadows (when focussed) not working correctly.

I'm using bootstrap 4.6.2

@JohnnyTWA
Copy link

Hello,

Thanks for creating the function override. Is this available as an npm package? If not would you mind if I create one, you'll obviously be credited and include links back to the original function.

@johanlef
Copy link
Author

@JohnnyTWA Please do, I have been neglecting comments and updates on this for too long. I welcome you to make this an npm package so more people can create nice things. Thanks a lot!

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