javascriptcolorsnavigationcontrastcomputed-style

Making text change colour dynamically depending on the colour underneath it


I am trying to have the menu change between black & white depending on the colour underneath it. I have tried mix-blend-mode but it creates certain scenarios where the text just becomes illegible.

I have managed to get it to analyse the background and output a colour but it seems to just read itself when making the decision. I've tried to get around it (run the analysis on a :before that sits away from the text but I don't think it's working either...

The issue is: It recognises the background but it won't change the text colour to contrast against the background. Dark background = white text., light background = black text I've tried a few different ways but they would either flicker the colour or just not change the colour at all..

function getContrastRatio(color1, color2) {
  // Convert hex colors to RGB if necessary
  color1 = (color1.charAt(0) === '#') ? color1.substr(1) : color1;
  color2 = (color2.charAt(0) === '#') ? color2.substr(1) : color2;

  // Extract RGB values
  var r1 = parseInt(color1.substr(0, 2), 16);
  var g1 = parseInt(color1.substr(2, 2), 16);
  var b1 = parseInt(color1.substr(4, 2), 16);
  var r2 = parseInt(color2.substr(0, 2), 16);
  var g2 = parseInt(color2.substr(2, 2), 16);
  var b2 = parseInt(color2.substr(4, 2), 16);

  // Calculate relative luminance
  var lum1 = (Math.max(r1, g1, b1) + Math.min(r1, g1, b1)) / 2;
  var lum2 = (Math.max(r2, g2, b2) + Math.min(r2, g2, b2)) / 2;

  // Calculate contrast ratio
  var contrastRatio = (lum1 + 0.05) / (lum2 + 0.05);

  return contrastRatio;
}

function readBackgroundColor() {
  var menu = document.querySelector('.sticky-menu');
  var contentTop = menu.offsetTop + menu.offsetHeight;
  
  // Use the body as the default content element
  var content = document.body;

  // Iterate over all elements with class "colour" to find the one under the menu
  var colours = document.querySelectorAll('.colour');
  for (var i = 0; i < colours.length; i++) {
    var rect = colours[i].getBoundingClientRect();
    if (rect.top >= contentTop && colours[i] !== menu) { // Exclude the menu from consideration
      break;
    }
    content = colours[i];
  }

  // Check if the content element is a child of the menu
  if (!menu.contains(content)) {
    var computedStyle = window.getComputedStyle(content);
    var backgroundColor = computedStyle.backgroundColor;

    // Calculate contrast ratio for black and white text
    var blackContrast = getContrastRatio(backgroundColor, 'black');
    var whiteContrast = getContrastRatio(backgroundColor, 'white');

    // Choose the color with better contrast
    var textColor = blackContrast > whiteContrast ? 'black' : 'white';

    menu.style.color = textColor;

    console.log("Background:", backgroundColor, "Text:", textColor);
  }
}

// Event listener for scroll
window.addEventListener('scroll', readBackgroundColor);

// Initial call to read background color
readBackgroundColor();
body, html {padding:0;margin:0;}
.colour {height:250px;width:100vw;}

.black {background:black;}
.blue  {background:blue;}
.red   {background:red;}
  
.sticky-menu {
  position: fixed;
  top: 0;
  padding: 10px;
  line-height:0;
  font-size:50px;
}
<div class="sticky-menu">home</div>

<div class="colour black"></div>
<div class="colour white"></div>
<div class="colour blue"></div>
<div class="colour red"></div>
<div class="colour white"></div>


Solution

  • First issue was the parameters to the contrast ratio function. It seems that getComputedStyle returns the background in a rgb(25, 25, 25) format. Another issue was that .white class wasn't defined as background: white so it didn't return the desired computed background of rgb(255, 255, 255). And lastly, I took contrast function from this answer

    One last issue, this example provided as-is you can certainly shorten it.

    EDIT: The exact format of getComputedStyle background is not well defined. See this answer. This means further testing is needed to ensure format is indeed as expected, or handle all cases.

    const rgba2hex = (rgba) => `${rgba.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+\.{0,1}\d*))?\)$/).slice(1).map((n, i) => (i === 3 ? Math.round(parseFloat(n) * 255) : parseFloat(n)).toString(16).padStart(2, '0').replace('NaN', '')).join('')}`
    
    const RED = 0.2126;
    const GREEN = 0.7152;
    const BLUE = 0.0722;
    
    const GAMMA = 2.4;
    
    function luminance(r, g, b) {
      var a = [r, g, b].map((v) => {
        v /= 255;
        return v <= 0.03928 ?
          v / 12.92 :
          Math.pow((v + 0.055) / 1.055, GAMMA);
      });
      return a[0] * RED + a[1] * GREEN + a[2] * BLUE;
    }
    
    function contrast(rgb1, rgb2) {
      var lum1 = luminance(...rgb1);
      var lum2 = luminance(...rgb2);
      var brightest = Math.max(lum1, lum2);
      var darkest = Math.min(lum1, lum2);
      return (brightest + 0.05) / (darkest + 0.05);
    }
    
    function getContrastRatio(color1, color2) {
      // Convert hex colors to RGB if necessary
      color1 = rgba2hex(color1)
      color2 = rgba2hex(color2)
    
      // Extract RGB values
      var r1 = parseInt(color1.substr(0, 2), 16);
      var g1 = parseInt(color1.substr(2, 2), 16);
      var b1 = parseInt(color1.substr(4, 2), 16);
      var r2 = parseInt(color2.substr(0, 2), 16);
      var g2 = parseInt(color2.substr(2, 2), 16);
      var b2 = parseInt(color2.substr(4, 2), 16);
    
      return contrast([r1, g1, b1], [r2, g2, b2])
    }
    
    function readBackgroundColor() {
      var menu = document.querySelector('.sticky-menu');
      var contentTop = menu.offsetTop + menu.offsetHeight;
    
      // Use the body as the default content element
      var content = document.body;
    
      // Iterate over all elements with class "colour" to find the one under the menu
      var colours = document.querySelectorAll('.colour');
      for (var i = 0; i < colours.length; i++) {
        var rect = colours[i].getBoundingClientRect();
        if (rect.top >= contentTop && colours[i] !== menu) { // Exclude the menu from consideration
          break;
        }
        content = colours[i];
      }
    
      // Check if the content element is a child of the menu
      if (!menu.contains(content)) {
        var computedStyle = window.getComputedStyle(content);
        var backgroundColor = computedStyle.backgroundColor;
    
        // Calculate contrast ratio for black and white text
        var blackContrast = getContrastRatio(backgroundColor, 'rgb(0,0,0)');
        var whiteContrast = getContrastRatio(backgroundColor, 'rgb(255,255,255)');
    
        // Choose the color with better contrast
        var textColor = blackContrast > whiteContrast ? 'black' : 'white';
    
        menu.style.color = textColor;
    
        // console.log("Background:", backgroundColor, "blackContrast", blackContrast, "whiteContrast", whiteContrast);
      }
    }
    
    // Event listener for scroll
    window.addEventListener('scroll', readBackgroundColor);
    
    // Initial call to read background color
    readBackgroundColor();
    body,
    html {
      padding: 0;
      margin: 0;
    }
    
    .colour {
      height: 250px;
      width: 100vw;
    }
    
    .white {
      background: white;
    }
    
    .black {
      background: black;
    }
    
    .blue {
      background: blue;
    }
    
    .red {
      background: red;
    }
    
    .sticky-menu {
      position: fixed;
      top: 0;
      padding: 10px;
      line-height: 0;
      font-size: 50px;
    }
    <div class="sticky-menu">home</div>
    
    <div class="colour black"></div>
    <div class="colour white"></div>
    <div class="colour blue"></div>
    <div class="colour red"></div>
    <div class="colour white"></div>