Uniform luminance Figma snippet

Folks think that matching lightness or black values in HSL/HSB will create matching luminance. Folks are wrong. Relying on lightness/black for a color system will lead to strong differences in each hue’s ramp. However, matching the actual luminance of colors of colors gets a more uniform appearance.

For example, let’s take this color ramp using matching HSL lightness:

Now compare that to a ramp using matching luminance:

Each stop in the second ramp seems more congruent across hues than the first. And that’s because each color is sharing a calculated luminance.

Instructions

This script is intended to run within Figma through its plugin API. The fastest way to run this script is by copy-pasting within Scripter.

This script expects you to select different layers—each possessing a fill. When run, the script will calculate the average luminance of all fills and shift each layer’s fill color to match the average luminance.

            
const RED = 0.2126;
const GREEN = 0.7152;
const BLUE = 0.0722;

const GAMMA = 2.4;

const FILL_SEED = [{
	type: "SOLID",
	visible: true,
	opacity: 1,
	blendMode: "NORMAL",
	color: {
		r: 1,
		g: 1,
		b: 1
	},
	boundVariables: {}
}]

function luminance(rgb) {
  var a = rgb.map((v) => {
    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 setLuminance(rgb, lum) {
  
  let hsl = rgbToHsl(rgb);
  let origLum = luminance(rgb)
  let l2 = origLum;
  let inc = 0.0001;
  if(lum > l2) {
    inc = -inc;
  }
  while (  Math.round( luminance(hslToRgb([hsl[0], hsl[1], l2])) * 100 )/100 !== Math.round( lum * 100)/100  )  {
    l2 += (lum - luminance(hslToRgb([hsl[0], hsl[1], l2])))/2;
  }

  return hslToRgb([hsl[0], hsl[1], l2]);
}

function rgbToHsl(rgb) {
  let r = rgb[0];
  let g = rgb[1];
  let b = rgb[2];
  var max = Math.max(r, g, b), min = Math.min(r, g, b);
  var h, s, l = (max + min) / 2;

  if(max == min){
    h = s = 0; // achromatic
  } else{
    var 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;
  }

  return [h, s, l];
}

function hslToRgb(hsl){

  let h = hsl[0]
  let s = hsl[1]
  let l = hsl[2]
  var r, g, b;
  if(s == 0){
      r = g = b = l; 
  } else{
    var hue2rgb = function hue2rgb(p, q, t){
      if(t < 0) t += 1;
      if(t > 1) t -= 1;
      if(t < 1/6) return p + (q - p) * 6 * t;
      if(t < 1/2) return q;
      if(t < 2/3) return p + (q - p) * (2/3 - t) * 6;
      return p;
    }

    var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
    var p = 2 * l - q;
    r = hue2rgb(p, q, h + 1/3);
    g = hue2rgb(p, q, h);
    b = hue2rgb(p, q, h - 1/3);
  }

  return [r,g,b];
}

var colors = figma.currentPage.selection;
var lum;
var colorsRgb = []
var avgLum = 0;
for(var i = 0; i < colors.length; i++) {
	var rect = colors[i] as SceneNode
	var fill = rect.fills[0]
	var rgb = [fill.color.r, fill.color.g, fill.color.b]
	colorsRgb.push(rgb)
	lum = luminance(colorsRgb[i]);
  avgLum+=lum;
}

avgLum /= colors.length;

for(i = 0; i < colors.length; i++) {
	rect = colors[i] as SceneNode
	var fill = rect.fills[0]
	var rgb = [fill.color.r, fill.color.g, fill.color.b]
	var newRgb = setLuminance(rgb, avgLum);
	var newFill = FILL_SEED;
	newFill[0].color.r = newRgb[0]
	newFill[0].color.g = newRgb[1]
	newFill[0].color.b = newRgb[2]
  rect.fills = newFill;
}