Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URL's added here will be added as <link>s in order, and before the CSS in the editor. If you link to another Pen, it will include the CSS from that Pen. If the preprocessor matches, it will attempt to combine them before processing.

+ add another resource

JavaScript

Babel is required to process package imports. If you need a different preprocessor remove all packages first.

Add External Scripts/Pens

Any URL's added here will be added as <script>s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.

+ add another resource

Behavior

Save Automatically?

If active, Pens will autosave every 30 seconds after being saved once.

Auto-Updating Preview

If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.

Format on Save

If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.

Editor Settings

Code Indentation

Want to change your Syntax Highlighting theme, Fonts and more?

Visit your global Editor Settings.

HTML

              
                <link href="https://fonts.googleapis.com/css?family=Montserrat:400,600&display=swap" rel="stylesheet">

<div class="main_titles center">
  <h1>Optimal Overlay Finder</h1>
  <h2>For Readable Text on a Background Image</h2>
</div>

<div class="content_width content_grid">

  <div class="choose_image">
    <h3>Choose an image:</h3>
    <div class="img_btns">
      <button class="standard_btn sample_img_btn" data-url="https://assets.codepen.io/246719/blue-rose-pexels-photo-1482972.jpeg?width=1034&height=689&format=auto">Flower</button>
      <button class="standard_btn sample_img_btn" data-url="https://assets.codepen.io/246719/snow-pexels-photo-691668.jpeg?width=1012&height=675&fit=scale-down&format=auto">Snow</button>
      <button id="custom_img_btn" class="standard_btn sample_img_btn hide" data-url="">Custom</button>
      <label class="upload_label standard_btn">
        Upload
        <input type="file" id="uploader">
      </label>
    </div>
  </div>

  <div class="enter_text">
    <h3>Enter your text:</h3>
    <input id="foreground_text_input" type="text" name="" placeholder="Example text goes here">
  </div>

</div>

<div class="content_width color_choice_title">
  <h3>Choose your colors:</h3>
</div>

<div class="content_width content_grid">
  <label class="color_picker">
    Overlay color
    <span class="color_preview_holder">
      <span class="color_preview" id="overlay_color_preview"></span>
    </span>
    <input id="overlay_color_input" type="color" value="#000000">
  </label>
  <label class="color_picker">
    Text color
    <span class="color_preview_holder">
      <span class="color_preview" id="text_color_preview"></span>
    </span>
    <input id="text_color_input" type="color" value="#ffffff">
  </label>

</div>

<div class="image_showcase_section content_width content_grid">

  <div class="image_showcase before">
    <h3>Before:</h3>
    <div class="image_holder">
      <div class="bg_image_container">
        <img src="" alt="" class="bg_image js_bg_image">
      </div>
      <div class="foreground_text_container">
        <div class="foreground_text"></div>
      </div>
    </div>
  </div>
  <div class="image_showcase after">
    <h3>After:</h3>
    <div class="image_holder">
      <div class="bg_image_container">
        <img src="" alt="" class="bg_image js_bg_image">
      </div>
      <div id="overlay"></div>
      <div class="foreground_text_container">
        <div class="foreground_text"></div>
      </div>
    </div>
  </div>

</div>

<div class="output_area center">
  <div class="optimal_opacity_headline">Optimal overlay opacity:</div>
  <div class="optimal_opacity_value" id="optimal_opacity_output_container"></div>
  <div id="no_solution" class="hide">
    <div class="no_solution_heading">
      No Solution
    </div>
    <div class="no_solution_explanation">
      If your text doesn't have enough contrast with your image or your overlay color, there won't be an optimal solution. For example, there's no opacity that will make blue text show up on an overlay that's the same shade of blue.
    </div>
  </div>
</div>

<div style="display:none;">
  <img id="original_image" class="js_bg_image" src="" alt="" crossorigin="*">
</div>

<div class="debug">
  <h3>Original image placed in a canvas:</h3>
  <canvas id="image_canvas"></canvas>
</div>
              
            
!

CSS

              
                body {
  margin: 0px;
}
body, button, input {
  font-family: 'Montserrat', sans-serif;
  color: #556;
}
h1, h2, h3, h4, h5, h6 {
  font-weight: normal;
}

h1 {
  font-size: 60px;
  margin: 0px;
}
h2 {
  font-size: 30px;
  margin: 0px;
  color: #889;
}
h3 {
  font-size: 15px;
  font-weight: 600;
  text-transform: uppercase;
  margin: 0px 0px 10px;
}

.center {
  text-align: center;
}

.color_output {
  display: inline-block;
  border: 3px dashed #333;
  margin-right: 50px;
  width: 300px;
  height: 300px;
  color: #fff;
}

#canvas {
  width: 500px;
}

.debug {
  display: none;
}

#color_mixing_canvas {
  width: 500px;
  height: 100px;
  border: 3px dashed #333;
}

#overlay {
  background-color: #000;
  opacity: 0;
  width: 100%;
  height: 100%;
  position: absolute;
  left: 0;
  top: 0;
}

.image_showcase {
  min-height: 100px;
  text-align: left;
}

.image_holder {
  position: relative;
  background-color: #333;
  color: #fff;
  border: 1px solid #ddd;
}

.bg_image {
  width: 100%;
  display: block;
}

.foreground_text_container {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}
.foreground_text {
  font-size: 30px;
  padding: 40px;
}

input[type="text"] {
  box-sizing: border-box;
  width: 100%;
  padding: 20px;
  font-size: 20px;
  border: 1px solid #ddd;
}

.content_width {
  max-width: 1000px;
  margin: auto;
  padding: 0px 40px;
}
.content_grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-gap: 40px;
}

.main_titles {
  margin: 100px 40px 60px;
}

.image_showcase_section {
  margin-top: 42px;
  margin-bottom: 42px;
}

@media (max-width: 700px) {
  .content_grid {
    grid-template-columns: 1fr;
  }
}

input[type="file"],
input[type="color"] {
  display: none;
}

.img_btns {
  display: flex;
  flex-wrap: wrap;
  margin-bottom: -10px;
  margin-right: -10px;
}

.standard_btn {
  border: none;
  background-color: #dde4ea;
  color: #334;
  padding: 20px;
  font-size: 20px;
  display: block;
  text-align: center;
  cursor: pointer;
  margin-right: 10px;
  margin-bottom: 10px;
  transition: all 0.2s;
  flex: 1;
}
.standard_btn:hover {
  background-color: #334;
  color: #dde4ea;
}
.standard_btn:focus {
  outline: none;
}

.hide {
  display: none;
}

.color_picker {
  position: relative;
  background-color: #dde4ea;
  color: #334;
  padding: 20px;
  font-size: 20px;
  display: inline-block;
  text-align: center;
  cursor: pointer;
  transition: all 0.2s;
}
.color_picker:hover {
  background-color: #334;
  color: #dde4ea;
}

.color_choice_title {
  margin-top: 40px;
}

.color_preview_holder {
  display: inline-block;
  padding: 4px;
  background-color: #eee;
  border: 1px solid #aaa;
  height: calc(100% - 20px);
  width: 45px;
  box-sizing: border-box;
  margin-left: 20px;
  position: absolute;
  right: 10px;
  top: 10px;
}

.color_preview {
  box-sizing: border-box;
  display: block;
  background-color: white;
  border: 1px solid #aaa;
  width: 100%;
  height: 100%;
}

.output_area {
  margin-bottom: 80px;
}

.optimal_opacity_headline {
  font-size: 30px;
}

.optimal_opacity_value {
  font-size: 60px;
}

.no_solution_heading {
  font-size: 60px;
}
.no_solution_explanation {
  max-width: 680px;
  margin: 10px auto 0;
  padding: 0px 40px;
  text-align: left;
}




@media (max-width: 900px) {
  .main_titles {
    margin: 24px 40px;
  }
  h1 {
    font-size: 36px;
  }
  h2 {
    font-size: 20px;
  }
  .standard_btn {
    padding: 10px;
    font-size: 18px;
  }
  input[type="text"] {
    padding: 10px;
    font-size: 18px;
  }
  .color_choice_title {
    margin-top: 20px;
  }
  .color_picker {
    padding: 10px;
    font-size: 18px;
  }
  .color_preview_holder {
    padding: 2px;
    height: calc(100% - 10px);
    width: 34px;
    box-sizing: border-box;
    right: 5px;
    top: 5px;
  }
  .foreground_text {
    font-size: 20px;
    padding: 20px;
  }
  .image_showcase_section {
    margin: 20px auto;
  }
  .output_area {
    margin-bottom: 20px;
  }
}
              
            
!

JS

              
                class App {

  desiredContrast = 4.5
  // Contrast level AA = 4.5, Level AAA = 7
  // Reference: https://www.w3.org/WAI/WCAG21/quickref/?versions=2.0&showtechniques=143#qr-visual-audio-contrast-contrast

  state = {
    textColor: {r:255, g:255, b:255},
    overlayColor: {r:0, g:0, b:0},
  }

  elements = {
    uploader: document.getElementById('uploader'),
    optimal_opacity_output_container: document.getElementById('optimal_opacity_output_container'),
    image_canvas: document.getElementById('image_canvas'),
    overlay: document.getElementById('overlay'),
    custom_img_btn: document.getElementById('custom_img_btn'),
    foreground_text_input: document.getElementById('foreground_text_input'),
    foreground_text: document.querySelectorAll('.foreground_text'),
    sample_image_buttons: document.querySelectorAll('.sample_img_btn'),
    background_images: document.querySelectorAll('.js_bg_image'),

    text_color_input: document.getElementById('text_color_input'),
    overlay_color_input: document.getElementById('overlay_color_input'),

    text_color_preview: document.getElementById('text_color_preview'),
    overlay_color_preview: document.getElementById('overlay_color_preview'),

    no_solution: document.getElementById('no_solution'),

    original_image: document.getElementById('original_image'),
  }

  start() {
    this.attachUploader();
    this.attachTextUpdaters();
    this.storeColorsFromInputs();
    this.updateTextColor(this.state.textColor);
    this.attachOverlayUpdater();
    this.prepareSampleImages();
    this.attachColorChangeListeners();
  }

  attachTextUpdaters() {
    this.elements.foreground_text_input.addEventListener('keyup', () => {
      this.updateText(this.elements.foreground_text_input);
    });
    this.updateText(this.elements.foreground_text_input);
  }

  attachOverlayUpdater() {
    this.elements.original_image.addEventListener('load', () => {this.updateOverlay()});
  }

  prepareSampleImages() {
    const { sample_image_buttons } = this.elements;
    const firstImageUrl = sample_image_buttons[0].getAttribute('data-url');
    this.loadImage(firstImageUrl);
    sample_image_buttons.forEach((btn) => {
      btn.addEventListener('click', () => {
        const url = btn.getAttribute('data-url');
        this.loadImage(url);
      });
    });
  }

  attachColorChangeListeners() {
    document.querySelectorAll('input[type=color]').forEach((colorInput) => {
      colorInput.addEventListener('input', () => {
        this.storeColorsFromInputs();
        this.updateTextColor(this.state.textColor);
        this.updateOverlay();
      });
    });
  }

  attachUploader() {
    const { uploader } = this.elements;
    uploader.addEventListener('change', () => {
      const file = uploader.files[0];

      const reader = new FileReader();
      reader.onload = (e) => {
        const url = e.target.result;
        this.loadImage(url);
        this.updateCustomImgBtn(url);
      };
      reader.readAsDataURL(file);
    });
  }

  updateCustomImgBtn(url) {
    this.elements.custom_img_btn.classList.remove('hide');
    this.elements.custom_img_btn.setAttribute('data-url', url);
  }

  updateText(textInput) {
    this.elements.foreground_text.forEach((textBox) => {
      textBox.innerText = textInput.value || textInput.placeholder;
    });
  }

  updateTextColor(color) {
    this.elements.foreground_text.forEach((textBox) => {
      textBox.style.color = `rgb(
        ${color.r},
        ${color.g},
        ${color.b}
      )`;
    });
  }

  loadImage(url) {
    this.elements.background_images.forEach( (image) => {
      image.src = url;
    });
  }

  updateOverlay() {
    const { image_canvas, original_image } = this.elements;
    const { textColor, overlayColor } = this.state;

    const imagePixelColors = this.getImagePixelColorsUsingCanvas(original_image, image_canvas);

    const worstContrastColorInImage = this.getWorstContrastColorInImage(textColor, imagePixelColors);

    const optimalOpacity = this.findOptimalOverlayOpacity(textColor, overlayColor, worstContrastColorInImage, this.desiredContrast);

    this.showOptimalOpacity(optimalOpacity);
  }

  getImagePixelColorsUsingCanvas(image, canvas) {
    const ctx = canvas.getContext('2d');

    canvas.height = this.getCanvasHeightToMatchImageProportions(canvas, image);

    const sourceImageCoordinates = [0, 0, image.width, image.height];
    const destinationCanvasCoordinates = [0, 0, canvas.width, canvas.height];

    ctx.drawImage(
      image,
      ...sourceImageCoordinates,
      ...destinationCanvasCoordinates
    );

    // Remember getImageData only works for same-origin or cross-origin-enabled images.
    // See https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image for more info.
    const imagePixelColors = ctx.getImageData(...destinationCanvasCoordinates);

    return imagePixelColors;
  }

  getCanvasHeightToMatchImageProportions(canvas, image) {
    return (image.height / image.width) * canvas.width;
  }

  showOptimalOpacity(optimalOpacity) {
    const { overlay, optimal_opacity_output_container } = this.elements;

    optimal_opacity_output_container.innerHTML = optimalOpacity.toFixed(3);
    overlay.style.backgroundColor = `rgb(
      ${this.state.overlayColor.r},
      ${this.state.overlayColor.g},
      ${this.state.overlayColor.b}
    )`;
    this.elements.overlay.style.opacity = optimalOpacity;
  }

  getWorstContrastColorInImage(textColor, imagePixelColors) {

    let worstContrastColorInImage;
    let worstContrast = Infinity;

    for (let i = 0; i < imagePixelColors.data.length; i += 4) {
      let pixelColor = {
        r: imagePixelColors.data[i],
        g: imagePixelColors.data[i + 1],
        b: imagePixelColors.data[i + 2],
      };

      let contrast = this.getContrast(textColor, pixelColor);

      if(contrast < worstContrast) {
        worstContrast = contrast;
        worstContrastColorInImage = pixelColor;
      }
    }

    return worstContrastColorInImage;
  }

  getContrast(color1, color2) {
    const color1_luminance = this.getLuminance(color1);
    const color2_luminance = this.getLuminance(color2);

    const lighterColorLuminance = Math.max(color1_luminance, color2_luminance);
    const darkerColorLuminance = Math.min(color1_luminance, color2_luminance);

    const contrast = (lighterColorLuminance + 0.05) / (darkerColorLuminance + 0.05);
    return contrast;
  }

  getLuminance({r,g,b}) {
    return (0.2126 * this.getLinearRGB(r) + 0.7152 * this.getLinearRGB(g) + 0.0722 * this.getLinearRGB(b));
  }

  getLinearRGB(primaryColor_8bit) {
    // First convert from 8-bit rbg (0-255) to standard RGB (0-1)
    const primaryColor_sRGB = this.convert_8bit_RGB_to_standard_RGB(primaryColor_8bit);

    // Then convert from sRGB to linear RGB so we can use it to calculate luminance
    const primaryColor_RGB_linear = this.convert_standard_RGB_to_linear_RGB(primaryColor_sRGB);

    return primaryColor_RGB_linear;
  }

  convert_8bit_RGB_to_standard_RGB(primaryColor_8bit) {
    return primaryColor_8bit / 255;
  }

  convert_standard_RGB_to_linear_RGB(primaryColor_sRGB) {
    const primaryColor_linear = primaryColor_sRGB < 0.03928 ?
      primaryColor_sRGB/12.92 :
      Math.pow((primaryColor_sRGB + 0.055) / 1.055, 2.4);
    return primaryColor_linear;
  }

  getTextContrastWithImagePlusOverlay({textColor, overlayColor, imagePixelColor, overlayOpacity}) {
    const colorOfImagePixelPlusOverlay = this.mixColors(imagePixelColor, overlayColor, overlayOpacity);
    const contrast = this.getContrast(this.state.textColor, colorOfImagePixelPlusOverlay);
    return contrast;
  }

  mixColors(baseColor, overlayColor, overlayOpacity) {
    const mixedColor = {
      r: baseColor.r + (overlayColor.r - baseColor.r) * overlayOpacity,
      g: baseColor.g + (overlayColor.g - baseColor.g) * overlayOpacity,
      b: baseColor.b + (overlayColor.b - baseColor.b) * overlayOpacity,
    }
    return mixedColor;
  }

  findOptimalOverlayOpacity(textColor, overlayColor, worstContrastColorInImage, desiredContrast) {
    const isOverlayNecessary = this.isOverlayNecessary(textColor, worstContrastColorInImage, desiredContrast);
    if (!isOverlayNecessary) {
      return 0;
    }

    const opacityGuessRange = {
      lowerBound: 0,
      midpoint: 0.5,
      upperBound: 1,
    };

    let numberOfGuesses = 0;
    const maxGuesses = 8;
    const opacityLimit = 0.99;

    while (numberOfGuesses < maxGuesses) {
      numberOfGuesses++;
      const currentGuess = opacityGuessRange.midpoint;

      const contrastOfGuess = this.getTextContrastWithImagePlusOverlay({
        textColor,
        overlayColor,
        imagePixelColor: worstContrastColorInImage,
        overlayOpacity: currentGuess,
      });

      const isGuessTooLow = contrastOfGuess < desiredContrast;
      const isGuessTooHigh = contrastOfGuess > desiredContrast;

      if (isGuessTooLow) {
        opacityGuessRange.lowerBound = currentGuess;
      }
      else if (isGuessTooHigh) {
        opacityGuessRange.upperBound = currentGuess;
      }

      const newMidpoint = ((opacityGuessRange.upperBound - opacityGuessRange.lowerBound) / 2) + opacityGuessRange.lowerBound;
      opacityGuessRange.midpoint = newMidpoint;
    }

    const optimalOpacity = opacityGuessRange.midpoint;

    if (optimalOpacity > opacityLimit) {
      this.elements.optimal_opacity_output_container.classList.add('hide');
      this.elements.no_solution.classList.remove('hide');
      return opacityLimit;
    }

    this.elements.optimal_opacity_output_container.classList.remove('hide');
    this.elements.no_solution.classList.add('hide');
    return optimalOpacity;
  }

  isOverlayNecessary(textColor, worstContrastColorInImage, desiredContrast) {
    const contrastWithoutOverlay = this.getContrast(textColor, worstContrastColorInImage);
    return contrastWithoutOverlay < desiredContrast;
  }

  convertHexToRGB(hex) {
    const raw_hex = hex.replace(/#/g, '');
    const r = parseInt(raw_hex.substring(0,2), 16);
    const g = parseInt(raw_hex.substring(2,4), 16);
    const b = parseInt(raw_hex.substring(4,6), 16);
    return {r, g, b};
  }

  storeColorsFromInputs() {
    this.state.textColor = this.convertHexToRGB(this.elements.text_color_input.value);
    this.state.overlayColor = this.convertHexToRGB(this.elements.overlay_color_input.value);
    this.elements.text_color_preview.style.backgroundColor = this.elements.text_color_input.value;
    this.elements.overlay_color_preview.style.backgroundColor = this.elements.overlay_color_input.value;
  }

}

const app = new App();
app.start();


              
            
!
999px

Console