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>';
@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