Let’s say you want to detect some grayscale colors:

isGrayscale(
)
// returns true

A color is grayscale if the color’s red, green, blue (RGB) component values are exactly equal. A really common format for colors on the web is a 6 digit hexidecimal representation of the RGB component values: # + RRGGBB.

  • #FFFFFF
  • #000000
  • #808080
  • #FF0000
  • #00FF00
  • #0000FF

Knowing the above, detecting grayscale colors is simple:

function isGrayscale(color) {
  const { r, g, b } = parseColor(color);

  return r === g && r === b;
}

function parseColor(color) {
  const match = color.match(/#((?:[0-9A-F]{1,2}){3})/i);
  if (!match) {
    throw new Error('invalid color format');
  }
  const [r, g, b] = match[1]
    .repeat(2)
    .slice(-6)
    .match(/.{2}/g)
    .map(hex => parseInt(hex, 16));
  return {r, g, b};
}
isGrayscale('#FFFFFF')
isGrayscale('#000000')
isGrayscale('#808080')
isGrayscale('#FF0000')
isGrayscale('#00FF00')
isGrayscale('#0000FF')

Be aware that there are a boatload of valid formats for web colors. For production use you are better off using a library like d3-color to parse untrusted color values.

Anyway, the above code works great! I guess we are done. Hope you learned something! I should blog more often. See you next time!

Wait, what was that? You plugged some Bootstrap 4 gray colors into the function like isGrayscale('#adb5bd') and you got false? Well, I would indeed consider those Bootstrap grays to be “grayish” colors. However, those colors are not strictly grayscale colors according to the definition we agreed upon above. So yeah, it’s actually a good thing our function doesn’t match them. Closed as wontfix.

What? This blog post sucks? Okay, fine, there’s more we can do here. I’m no stranger to scope creep.

Classifying colors as “grayish”

A color is generally considered “grayish”, neutral, achromatic if all of its red, green, blue (RGB) component values are a close distance to each other. Makes sense: we know from earlier that a color is strictly grayscale if it has uniform RGB values. A distance of zero is about as close as you can get.

Let’s define our test cases. We can use those pesky Bootstrap 4 gray colors as colors we want to positively match.

  • gray-100: #f8f9fa
  • gray-200: #e9ecef
  • gray-300: #dee2e6
  • gray-400: #ced4da
  • gray-500: #adb5bd
  • gray-600: #6c757d
  • gray-700: #495057
  • gray-800: #343a40
  • gray-900: #212529

As a sanity check let’s use ColorBrewer 2 as colors we want to negatively match.

Let’s visualize the RGB values of our test cases and check if we see the pattern we expect.

True Grayscale
Bootstrap 4
ColorBrewer 2

Okay, so we can clearly see the pattern we expect. The colors we want to match have equidistant RGB components. The ColorBrewer colors are off doing their own thing because they are clearly not gray colors.

If we want to classify these colors in code: we need a way to measure the degree to which the RGB components are close to each other. The range of the RGB components should measure this nicely.

const RANGE_LIMIT = 17;

function isGray(color) {
  const { r, g, b } = parseColor(color);
  const rgb = [r, g, b];
  return Math.max(...rgb) - Math.min(...rgb) <= RANGE_LIMIT;
}
Where do we get RANGE_LIMIT?
redgreenbluerange
#f8f9fa2482492502
#e9ecef2332362396
#dee2e62222262308
#ced4da20621221812
#adb5bd17318118916
#6c757d10811712517
#495057738087 14
#343a40525864 12
#212529333741 8

This matches the original desired Bootstrap colors:

Similar “grayish” colors will be matched as well:

Great! Looks good to me so far. Now, how can we exhaustively prove that this function matches only the colors we want? The best way to spot check our function is probably to visualize what it considers to be “grayish”.

Checking our work

RGB is a nice and simple color model that is easy for machines to deal with. However, for our purposes here it would be nice to use a human-friendly color model. I will choose the hue, saturation, value (HSV) color model to make a simple 2D plot of the colors matched by our function.

HSV has some useful geometry for detecting and visualizing grayish colors. The HSV color model can be represented as a cylinder where the central vertical axis comprises neutral, achromatic, or gray colors. Those colors range, from top to bottom, white at value 1 and black at value 0.

hsv cylinder By HSV_color_solid_cylinder.png: SharkDderivative work: SharkD  Talk - HSV_color_solid_cylinder.png, CC BY-SA 3.0, wiki

I’m not smart enough to do math in 3D: so let’s just chart 2D slices of this cylinder at different hues and see how far we get. We are mostly concerned with the interaction between saturation and value right now anyway. Let’s fix the hue to 210°. since all those Bootstrap grays tend to hover around that hue.

hexsaturationvalue
#f0f7ff 6 100
#ebf4fc 7 99
#ebf2fa 6 98
#e6eff7 7 97
#e4ecf5 7 96
#e1eaf2 7 95
#dfe7f0 7 94
#dde5ed 7 93
#dae2eb 7 92
#d8e0e8 7 91
#d5dde6 7 90
#d3dbe3 7 89
#d1d9e0 7 88
#ced6de 7 87
#cad3db 8 86
#cad1d9 7 85
#c5ced6 8 84
#c3cbd4 8 83
#c0c9d1 8 82
#bec6cf 8 81
#bcc4cc 8 80
#b9c1c9 8 79
#b7bfc7 8 78
#b3bcc4 9 77
#b2bac2 8 76
#aeb7bf 9 75
#acb4bd 9 74
#a9b2ba 9 73
#a7afb8 9 72
#a5adb5 9 71
#a2aab3 9 70
#a0a8b0 9 69
#9ca5ad 10 68
#9aa2ab 10 67
#97a0a8 10 66
#959da6 10 65
#939ba3 10 64
#9199a1 10 63
#8d959e 11 62
#8c949c 10 61
#889199 11 60
#868e96 11 59
#848c94 11 58
#808991 12 57
#7e868f 12 56
#7b848c 12 55
#79818a 12 54
#767e87 13 53
#757d85 12 52
#717a82 13 51
#6f7780 13 50
#6d757d 13 49
#69727a 14 48
#676f78 14 47
#646d75 15 46
#626a73 15 45
#5f6870 15 44
#5d656e 15 43
#5a636b 16 42
#586069 16 41
#555d66 17 40
#525a63 17 39
#505961 18 38
#4d565e 18 37
#4b545c 18 36
#485159 19 35
#464e57 20 34
#434c54 20 33
#414952 21 32
#3e464f 22 31
#3c444d 22 30
#39414a 23 29
#363e47 24 28
#343c45 25 27
#313a42 26 26
#2f3740 27 25
#2c353d 28 24
#2a323b 29 23
#272f38 30 22
#252d36 31 21
#222a33 33 20
#1f2730 35 19
#1d252e 37 18
#1a222b 40 17
#182029 41 16
#151d26 45 15
#131b24 47 14
#101821 52 13
#0e161f 55 12
#0b131c 61 11
#09111a 65 10
#060e17 74 9
#030c14 85 8
#010912 94 7
#00080f 100 6
#00060d 100 5
#00050a 100 4
#000408 100 3
#000305 100 2
#000103 100 1
The blue line is the maximum color matched by isGray when it is fed colors with a hue fixed at 210°. Colors below this line will be matched. Our target Bootstrap colors are plotted in red.

Great! Not perfect, but it’s definitely clear to me that we are on the right track.

Let’s check another hue to see what our function matches there. Let’s check the 120° hue.

hexsaturationvalue
#f0fff0 6 100
#ebfceb 7 99
#ebfaeb 6 98
#e6f7e6 7 97
#e4f5e4 7 96
#e1f2e1 7 95
#dff0df 7 94
#ddeddd 7 93
#daebda 7 92
#d8e8d8 7 91
#d5e6d5 7 90
#d3e3d3 7 89
#d1e0d1 7 88
#cedece 7 87
#cadbca 8 86
#cad9ca 7 85
#c5d6c5 8 84
#c3d4c3 8 83
#c0d1c0 8 82
#becfbe 8 81
#bcccbc 8 80
#b9c9b9 8 79
#b7c7b7 8 78
#b3c4b3 9 77
#b2c2b2 8 76
#aebfae 9 75
#acbdac 9 74
#a9baa9 9 73
#a7b8a7 9 72
#a5b5a5 9 71
#a2b3a2 9 70
#a0b0a0 9 69
#9cad9c 10 68
#9aab9a 10 67
#97a897 10 66
#95a695 10 65
#93a393 10 64
#91a191 10 63
#8d9e8d 11 62
#8c9c8c 10 61
#889988 11 60
#869686 11 59
#849484 11 58
#809180 12 57
#7e8f7e 12 56
#7b8c7b 12 55
#798a79 12 54
#768776 13 53
#758575 12 52
#718271 13 51
#6f806f 13 50
#6d7d6d 13 49
#697a69 14 48
#677867 14 47
#647564 15 46
#627362 15 45
#5f705f 15 44
#5d6e5d 15 43
#5a6b5a 16 42
#586958 16 41
#556655 17 40
#526352 17 39
#506150 18 38
#4d5e4d 18 37
#4b5c4b 18 36
#485948 19 35
#465746 20 34
#435443 20 33
#415241 21 32
#3e4f3e 22 31
#3c4d3c 22 30
#394a39 23 29
#364736 24 28
#344534 25 27
#314231 26 26
#2f402f 27 25
#2c3d2c 28 24
#2a3b2a 29 23
#273827 30 22
#253625 31 21
#223322 33 20
#1f301f 35 19
#1d2e1d 37 18
#1a2b1a 40 17
#182918 41 16
#152615 45 15
#132413 47 14
#102110 52 13
#0e1f0e 55 12
#0b1c0b 61 11
#091a09 65 10
#061706 74 9
#031403 85 8
#011201 94 7
#000f00 100 6
#000d00 100 5
#000a00 100 4
#000800 100 3
#000500 100 2
#000300 100 1

Uhoh. Yeah, this isn’t working at all. Most all of these matched colors look downright green to me on all my devices. At best, it seems to be matching greens that look like olive drab.

If you are not familiar with color spaces: you might be wondering how #f0f7ff and #f0fff0 can have the same saturation (6) and value (100) yet #f0fff0 is disproportionately offensive. Due to the way color vision works, we are naturally more sensitive to colors in the greenish-yellow area of the color spectrum. So we need to use a perceptually uniform color model that accounts for this.

Perceiving colors in code

Let’s tighten up our terminology in the hopes of finding a good color model for our problem. Classifying colors as “grayish” can be more accurately stated as classifying:

  • neutral colors
  • colors with low colorfulness
  • colors with low saturation
  • achromatic colors, colors with a low chroma value

If we are looking for achromatic colors, we have the CIELChab perceptual color model at our disposal. This is a cylindrical representation of the CIELAB color space with a channel for Lightness, Chroma, and hue. The chroma channel is what we will use to determine if a color is achromatic enough to be matched. #6c757d from our bootstrap colors has the highest chroma value at 6.05. Let’s use that as our threshold.

import { lch } from 'd3-color';

const CHROMA_THRESHOLD = 6.05;

function isGray(color) {
  let chroma = lch(color).c;
  return isNaN(chroma) || chroma <= CHROMA_THRESHOLD;
}

Let’s chart it at the 210° hue:

hexsaturationvalue
#edf6ff 7 100
#ebf4fc 7 99
#e8f1fa 7 98
#e6eff7 7 97
#e4ecf5 7 96
#e1eaf2 7 95
#dfe7f0 7 94
#dde5ed 7 93
#dae2eb 7 92
#d5dfe8 8 91
#d3dce6 8 90
#d1dae3 8 89
#ced7e0 8 88
#ccd5de 8 87
#cad3db 8 86
#c7d0d9 8 85
#c5ced6 8 84
#c3cbd4 8 83
#c0c9d1 8 82
#bec6cf 8 81
#bcc4cc 8 80
#b7c0c9 9 79
#b5bec7 9 78
#b3bcc4 9 77
#b0b9c2 9 76
#aeb7bf 9 75
#acb4bd 9 74
#a9b2ba 9 73
#a7afb8 9 72
#a5adb5 9 71
#a2aab3 9 70
#9ea7b0 10 69
#9ca5ad 10 68
#9aa2ab 10 67
#97a0a8 10 66
#959da6 10 65
#939ba3 10 64
#9199a1 10 63
#8d959e 11 62
#8a939c 12 61
#889199 11 60
#868e96 11 59
#848c94 11 58
#818991 11 57
#7f878f 11 56
#7b848c 12 55
#79818a 12 54
#777f87 12 53
#757d85 12 52
#727a82 12 51
#6f7780 13 50
#6d757d 13 49
#6a727a 13 48
#687078 13 47
#656d75 14 46
#636b73 14 45
#606870 14 44
#5e666e 15 43
#5b636b 15 42
#596169 15 41
#575e66 15 40
#545b63 15 39
#515961 16 38
#4f575e 16 37
#4c545c 17 36
#4a5259 17 35
#474f57 18 34
#454d54 18 33
#424a52 20 32
#40484f 19 31
#3d454d 21 30
#3b434a 20 29
#384047 21 28
#363e45 22 27
#343b42 21 26
#313840 23 25
#2f363d 23 24
#2c333b 25 23
#2a3138 25 22
#272e36 28 21
#252c33 27 20
#222930 29 19
#20272e 30 18
#1d242b 33 17
#1b2229 34 16
#181f26 37 15
#161d24 39 14
#141a21 39 13
#11181f 45 12
#0e151c 50 11
#0a121a 62 10
#060e17 74 9
#010b14 95 8
#000912 100 7
#00080f 100 6
#00060d 100 5
#00050a 100 4
#000408 100 3
#000305 100 2
#000103 100 1
The blue line is our new LCh-based isGray. The purple line is our old range-based isGray. Great! It's mostly the same as our previous function. Glad to see we weren't completely off-base with our previous idea.

Let’s chart it at the 120° hue:

hexsaturationvalue
#f7fff7 3 100
#f2fcf2 4 99
#f0faf0 4 98
#edf7ed 4 97
#ebf5eb 4 96
#e9f2e9 4 95
#e6f0e6 4 94
#e4ede4 4 93
#e1ebe1 4 92
#dfe8df 4 91
#dce6dc 4 90
#dae3da 4 89
#d7e0d7 4 88
#d5ded5 4 87
#d3dbd3 4 86
#d0d9d0 4 85
#ced6ce 4 84
#cbd4cb 4 83
#c9d1c9 4 82
#c6cfc6 4 81
#c4ccc4 4 80
#c1c9c1 4 79
#bfc7bf 4 78
#bcc4bc 4 77
#bac2ba 4 76
#b6bfb6 5 75
#b3bdb3 5 74
#b1bab1 5 73
#aeb8ae 5 72
#acb5ac 5 71
#aab3aa 5 70
#a7b0a7 5 69
#a5ada5 5 68
#a2aba2 5 67
#a0a8a0 5 66
#9da69d 5 65
#9ba39b 5 64
#99a199 5 63
#969e96 5 62
#949c94 5 61
#909990 6 60
#8d968d 6 59
#8b948b 6 58
#899189 6 57
#868f86 6 56
#848c84 6 55
#818a81 7 54
#7f877f 6 53
#7d857d 6 52
#7a827a 6 51
#778077 7 50
#747d74 7 49
#727a72 7 48
#6f786f 7 47
#6d756d 7 46
#6b736b 7 45
#687068 7 44
#666e66 7 43
#636b63 7 42
#606960 9 41
#5e665e 8 40
#5b635b 8 39
#596159 8 38
#575e57 7 37
#545c54 9 36
#515951 9 35
#4f574f 9 34
#4d544d 8 33
#495249 11 32
#474f47 10 31
#454d45 10 30
#434a43 9 29
#404740 10 28
#3d453d 12 27
#3a423a 12 26
#384038 13 25
#363d36 11 24
#333b33 14 23
#313831 12 22
#2e362e 15 21
#2c332c 14 20
#293029 15 19
#272e27 15 18
#242b24 16 17
#212921 20 16
#1f261f 18 15
#1d241d 19 14
#1a211a 21 13
#181f18 23 12
#151c15 25 11
#121a12 31 10
#0f170f 35 9
#0b140b 45 8
#071207 61 7
#030f03 80 6
#000d00 100 5
#000a00 100 4
#000800 100 3
#000500 100 2
#000300 100 1
The blue line is our new LCh-based isGray. The purple line is our old range-based isGray. You can see the LCh-based curve is way more reluctant to match saturated colors for a given brightness at this hue. It plays it safe and sticks closer to the Y axis.

For completeness let’s try Let’s chart it at the hue:

hexsaturationvalue
#fff0f0 6 100
#fceded 6 99
#faebeb 6 98
#f7e9e9 6 97
#f5e6e6 6 96
#f2e4e4 6 95
#f0e1e1 6 94
#eddfdf 6 93
#ebdddd 6 92
#e8dada 6 91
#e6d8d8 6 90
#e3d5d5 6 89
#e0d1d1 7 88
#decece 7 87
#dbcccc 7 86
#d9caca 7 85
#d6c7c7 7 84
#d4c5c5 7 83
#d1c2c2 7 82
#cfc0c0 7 81
#ccbebe 7 80
#c9bbbb 7 79
#c7b9b9 7 78
#c4b7b7 7 77
#c2b4b4 7 76
#bfb0b0 8 75
#bdaeae 8 74
#baabab 8 73
#b8a9a9 8 72
#b5a7a7 8 71
#b3a4a4 8 70
#b0a2a2 8 69
#ada0a0 8 68
#ab9d9d 8 67
#a89b9b 8 66
#a69898 8 65
#a39595 9 64
#a19292 9 63
#9e9090 9 62
#9c8e8e 9 61
#998b8b 9 60
#968989 9 59
#948787 9 58
#918484 9 57
#8f8181 10 56
#8c7e7e 10 55
#8a7c7c 10 54
#877a7a 10 53
#857777 11 52
#827575 10 51
#807171 12 50
#7d6f6f 11 49
#7a6d6d 11 48
#786b6b 11 47
#756868 11 46
#736565 12 45
#706363 12 44
#6e6060 13 43
#6b5e5e 12 42
#695c5c 12 41
#665959 13 40
#635757 12 39
#615454 13 38
#5e5151 14 37
#5c4f4f 14 36
#594d4d 13 35
#574a4a 15 34
#544848 14 33
#524545 16 32
#4f4242 16 31
#4d4040 17 30
#4a3d3d 18 29
#473b3b 17 28
#453838 19 27
#423636 18 26
#403434 19 25
#3d3232 18 24
#3b2f2f 20 23
#382c2c 21 22
#362a2a 22 21
#332828 22 20
#302525 23 19
#2e2323 24 18
#2b2020 26 17
#291e1e 27 16
#261c1c 26 15
#241919 31 14
#211717 30 13
#1f1414 35 12
#1c1010 43 11
#1a0c0c 54 10
#170707 70 9
#140303 85 8
#120000 100 7
#0f0000 100 6
#0d0000 100 5
#0a0000 100 4
#080000 100 3
#050000 100 2
#030000 100 1
The blue line is our new LCh-based isGray. The purple line is our old range-based isGray.

Let’s sample the edge case colors:

#fff0f0 #f7fff7 #edf6ff #807171 #778077 #6f7780 #403434 #384038 #313840
#fff0f0 #f7fff7 #edf6ff #807171 #778077 #6f7780 #403434 #384038 #313840
#fff0f0 #f7fff7 #edf6ff #807171 #778077 #6f7780 #403434 #384038 #313840

We are definitely getting somewhere. At this point I’m beginning to doubt my monitor calibration/eyes. Regardless, I’m gonna go ahead and judge that most people would struggle to classify the above colors as “gray”. If I asked people to name those colors I’m guessing they would say things like “pale blue”, “olive drab”, “dark blue”.

Here’s an example of me manually tweaking these colors in an LCh color picker until I personally would call them “gray”.

#f8f3f3 #fbfefb #f1f5f9 #7a7373 #7c7e7c #71777d #3c3635 #3d3f3d #34383c
#f8f3f3 #fbfefb #f1f5f9 #7a7373 #7c7e7c #71777d #3c3635 #3d3f3d #34383c
#f8f3f3 #fbfefb #f1f5f9 #7a7373 #7c7e7c #71777d #3c3635 #3d3f3d #34383c

That is the best I could do in 10 minutes or so. I’m hesitant to tweak further as there are so many equipment and biological factors that could cause me to waste a ton of time with diminising returns. The limitations of human vision could certainly trip me up.

optical illusion

Anyway, the above exercise of manually finding the chroma threshold for those example colors was useful. Let’s encode my common sense judgement above into an algorithm.

Coding common sense

When determining a chroma threshold for our algorithm, we should consider these findings from above:

  • Blue colors should be given lots of leeway. Blues with a low chroma look like “cool grays” to me.
  • Orange colors should be given lots of leeway. Orange is a gateway to brown. Oranges with low chroma look like “warm grays” to me.
  • Red colors should be judged somewhere in the middle.
  • Green colors should be scrutinized closely.

Let’s slice up our 3D problem into 2D projections and apply some shader functions to our classifier curves to approximate our desired result.

function _clamp(x, min, max) {
  if (x < min) {
    x = min
  } else if (x > max) {
    x = max
  }
  return x
}

function _smoothstep(a, b, x) {
  const t = _clamp((x - a) / (b - a), 0.0, 1.0);
  return t * t * (3.0 - 2.0 * t);
}

const hueToleranceStopsV1 = [
  [0, 3.1], // red
  [20, 3.1], // orange
  [61.8, 8], // orange
  [66, 7.49], // orange
  [75, 6.8], // orange
  [100, 2], // yellow-green
  [180, 2], // green
  [240, 5.4], // blue
  [262, 14], // blue
  [263, 14], // blue
  [290, 6.5],
  [310, 3.1], // red
  [360, 3.1],
];

function _buildCurve(stops) {
  return function (value) {
    let a, b, from, to;
    for (const [x, y] of stops) {
      if (value >= x) {
        a = x;
        from = y;
      }
      if (value <= x) {
        b = x;
        to = y;
        break;
      }
    }
    if (a === b) {
      return to;
    }
    return _smoothstep(a, b, value) * (to - from) + from;
  }
}

const hueCurveLCh = _buildCurve(hueToleranceStopsV1);

function isGray(color) {
  const {l: lightness, c: chroma, h: hue} = lch(color);
  // black and white
  if ([0, 100].includes(lightness)) {
    return true;
  }
  // grayscale
  if (chroma === 0) {
    return true;
  }
  return chroma <= hueCurveLCh(hue)
}

Below is a visualization of hueCurveLCh:

hue sensitivity
The blue line is how much leeway I want to give our chroma threshold for a given hue. The higher the value the more leeway we give it.

Let’s dump all the edge case values this function matches. The following table shows the most chromatic colors matched for a given hue and lightness. These colors sit at the threshold of what this function considers “gray”.

#ffffff #000000
0° HSV / 20° LCh #fff7f7#e6dfdf#ccc6c6#b3abab#999393#807979#666060#4d4646#332d2d#1a1414
5° HSV / 27° LCh #fff8f7#e6dddc#ccc5c4#b3aaaa#999291#807878#665f5e#4d4545#332c2c#1a1313
10° HSV / 35° LCh #fff4f2#e6dcda#ccc2c0#b3a8a6#998f8d#807674#665d5b#4d4442#332b29#1a1210
15° HSV / 43° LCh #fff2ed#e6d8d3#ccc0bc#b3a6a2#998c88#807470#665b57#4d423e#332926#1a100d
20° HSV / 52° LCh #fff0e8#e6d8d1#ccbeb8#b3a49d#998c85#80736c#665a54#4d413b#332823#1a0e08
25° HSV / 61° LCh #fff2e8#e6d8cf#ccbfb6#b3a59b#998d84#80736a#665a52#4d4139#332921#1a0d04
30° HSV / 70° LCh #fff5eb#e6dbd1#ccc2b8#b3a99f#998f85#80766c#665d54#4d443b#332b23#1a0d01
35° HSV / 78° LCh #fff8ed#e6ded3#ccc4ba#b3aba1#999288#80796f#665f56#4d463d#332d25#1a1004
40° HSV / 85° LCh #fffbf2#e6e2da#ccc8c0#b3aea6#99958d#807c74#66625b#4d4941#332f28#1a1409
45° HSV / 92° LCh #fffdf7#e6e4df#cccac6#b3b1ab#999791#807e79#66645f#4d4b46#33312c#1a1813
50° HSV / 97° LCh #fffefa#e6e5e1#cccbc8#b3b2af#999894#807f7c#666562#4d4c49#33322f#1a1916
55° HSV / 101° LCh #fffffc#e6e5e3#ccccc8#b3b2af#999996#807f7c#666663#4d4c49#333330#1a1917
60° HSV / 105° LCh #fffffc#e6e6e3#ccccca#b3b3af#999996#80807d#666663#4d4d49#333330#1a1a17
65° HSV / 109° LCh #fffffc#e5e6e3#ccccca#b2b3af#999996#7f807d#666663#4c4d49#333330#191a17
70° HSV / 112° LCh #fffffc#e5e6e3#ccccca#b2b3af#989996#7f807d#656663#4c4d49#333330#191a17
75° HSV / 116° LCh #fefffc#e5e6e3#cbccca#b2b3af#989996#7f807d#656663#4c4d49#323330#191a17
80° HSV / 119° LCh #fefffc#e5e6e3#cbccca#b1b3af#989996#7f807d#656663#4b4d49#323330#191a17
85° HSV / 122° LCh #fefffc#e5e6e3#cbccca#b1b3af#989996#7e807d#656663#4b4d49#323330#181a17
90° HSV / 126° LCh #fefffc#e4e6e3#cbccca#b1b3af#979996#7e807d#646663#4b4d49#323330#181a17
95° HSV / 129° LCh #fefffc#e4e6e3#cbccca#b1b3b1#979996#7e807d#646663#4b4d49#323330#181a17
100° HSV / 132° LCh #fdfffc#e4e6e3#cbccca#b1b3b1#979996#7e807d#646663#4b4d4a#313330#181a17
105° HSV / 135° LCh #fdfffc#e4e6e3#caccca#b1b3b1#979996#7e807d#646663#4b4d4a#313330#181a17
110° HSV / 138° LCh #fdfffc#e4e6e3#caccca#b1b3b1#969996#7d807d#646664#4b4d4a#313330#181a17
115° HSV / 140° LCh #fdfffc#e3e6e3#caccca#b1b3b1#969996#7d807d#646664#4a4d4a#313330#171a17
120° HSV / 143° LCh #fcfffc#e3e6e3#caccca#b1b3b1#969996#7d807d#646664#4a4d4a#313331#171a17
125° HSV / 145° LCh #fcfffd#e3e6e3#caccca#b1b3b1#969996#7d807d#646664#4a4d4a#303331#171a17
130° HSV / 148° LCh #fcfffd#e3e6e4#caccca#b1b3b1#969996#7d807d#636663#4a4d4b#303331#171a17
135° HSV / 152° LCh #fcfffd#e3e6e4#caccca#afb3b0#969997#7d807e#636664#494d4a#303331#171a18
140° HSV / 155° LCh #fcfffd#e3e6e4#cacccb#afb3b0#969997#7d807e#636664#494d4a#303331#171a18
145° HSV / 159° LCh #fcfffe#e3e6e4#c8ccca#afb3b0#969997#7c807d#636664#494d4b#303331#171a18
150° HSV / 164° LCh #fcfffe#e1e6e3#c8ccca#afb3b1#969997#7c807e#636664#494d4b#303331#161a18
155° HSV / 169° LCh #fcfffe#e1e6e4#c8ccca#afb3b1#969998#7c807e#626664#494d4b#2f3332#161a18
160° HSV / 174° LCh #fafffd#e1e6e4#c8cccb#afb3b1#949997#7c807e#626665#494d4b#2f3332#161a18
165° HSV / 180° LCh #fafffe#e1e6e4#c8cccb#afb3b2#949998#7c807f#626665#494d4c#2f3332#161a19
170° HSV / 186° LCh #fafffe#e1e6e5#c8cccb#adb3b2#949998#7c807f#626665#484d4c#2f3332#161a19
175° HSV / 193° LCh #faffff#e1e6e5#c6cccb#adb3b2#949999#7a807f#616666#474d4c#2e3333#151a19
180° HSV / 200° LCh #f7ffff#dfe6e6#c6cccc#abb3b3#939999#798080#606666#464d4d#2d3333#141a1a
185° HSV / 207° LCh #f7feff#dce5e6#c4cbcc#aab2b3#909899#777f80#5e6566#454c4d#2b3233#12191a
190° HSV / 215° LCh #f2fdff#dae4e6#c0cacc#a8b1b3#8e9799#757e80#5c6466#434b4d#293133#0f181a
195° HSV / 224° LCh #f0fbff#d8e2e6#bec8cc#a4afb3#8b9699#737c80#596366#40494d#273033#0c161a
200° HSV / 233° LCh #f0faff#d5e0e6#bcc7cc#a2adb3#8a9499#707a80#586166#3f484d#262f33#0b151a
205° HSV / 242° LCh #edf8ff#d5dfe6#bcc5cc#a2acb3#8a9399#707980#576066#3e464d#252d33#08121a
210° HSV / 251° LCh #ebf5ff#d3dce6#bac3cc#a1aab3#879099#6e7780#555d66#3c444d#242b33#09111a
215° HSV / 259° LCh #edf5ff#d3dbe6#bac1cc#a1a8b3#888f99#6f7680#575d66#3d444d#252b33#0a111a
220° HSV / 266° LCh #edf3ff#d5dbe6#bcc1cc#a2a8b3#8a8f99#707580#585c66#3f434d#262a33#0c101a
225° HSV / 272° LCh #f0f4ff#d5d9e6#bec1cc#a4a8b3#8b8f99#717580#595c66#3f434d#272a33#0d101a
230° HSV / 278° LCh #f2f4ff#d8dae6#bec0cc#a6a8b3#8b8e99#737580#5a5c66#41434d#282a33#0e101a
235° HSV / 282° LCh #f2f3ff#dadbe6#c0c1cc#a6a7b3#8d8e99#747580#5b5c66#42434d#292a33#0f101a
240° HSV / 286° LCh #f2f2ff#dadae6#c0c0cc#a8a8b3#8e8e99#757580#5c5c66#43434d#2a2a33#10101a
245° HSV / 289° LCh #f3f2ff#dbdae6#c1c0cc#a9a8b3#8f8e99#767580#5d5c66#43434d#2b2a33#10101a
250° HSV / 292° LCh #f7f5ff#dcdae6#c4c2cc#aaa8b3#908e99#777580#5e5c66#44434d#2b2a33#11101a
255° HSV / 296° LCh #f7f5ff#dfdce6#c4c2cc#acaab3#929099#797780#5f5d66#46444d#2d2b33#13111a
260° HSV / 299° LCh #faf7ff#dfdce6#c7c4cc#adaab3#949199#7a7880#615e66#47454d#2e2c33#14121a
265° HSV / 303° LCh #fbf7ff#e1dfe6#c8c6cc#aeabb3#959399#7c7980#626066#49464d#302d33#16131a
270° HSV / 306° LCh #fcfaff#e3e1e6#c9c6cc#b0adb3#979499#7d7a80#636166#4a484d#312e33#17151a
275° HSV / 309° LCh #fdfaff#e4e1e6#cac8cc#b0adb3#979499#7d7a80#646266#4b484d#312f33#18151a
280° HSV / 312° LCh #fdfaff#e4e1e6#cbc8cc#b1adb3#979499#7e7c80#656266#4b484d#322f33#18161a
285° HSV / 315° LCh #fefaff#e4e1e6#cbc8cc#b2afb3#989499#7f7c80#656266#4c494d#322f33#19161a
290° HSV / 318° LCh #fefaff#e5e1e6#cbc8cc#b2afb3#989499#7f7c80#656266#4c494d#322f33#19161a
295° HSV / 321° LCh #fffaff#e5e1e6#ccc8cc#b2afb3#999499#7f7c80#666266#4c494d#332f33#19161a
300° HSV / 323° LCh #fffaff#e6e1e6#ccc8cc#b3afb3#999499#807c80#666266#4d494d#332f33#1a161a
305° HSV / 326° LCh #fffaff#e6e1e5#ccc8cc#b3afb2#999499#807c7f#666266#4d494c#332f33#1a1619
310° HSV / 329° LCh #fffafe#e6e1e5#ccc8cb#b3adb2#999498#807a7f#666265#4d484c#332f32#1a1619
315° HSV / 332° LCh #fffafe#e6e1e4#ccc8cb#b3adb1#999498#807a7e#666165#4d484b#332e32#1a1518
320° HSV / 335° LCh #fffafd#e6e1e4#ccc6ca#b3adb1#999497#807a7e#666164#4d484b#332e31#1a1518
325° HSV / 340° LCh #fffafd#e6e1e4#ccc6c9#b3adb0#999396#807a7d#666164#4d474a#332e31#1a1518
330° HSV / 344° LCh #fffafc#e6dfe2#ccc6c9#b3adb0#999396#80797c#666063#4d474a#332e30#1a1417
335° HSV / 349° LCh #fffafc#e6dfe1#ccc6c8#b3adaf#999395#80797c#666062#4d4749#332d30#1a1416
340° HSV / 355° LCh #fff7fa#e6dfe1#ccc6c8#b3abae#999395#80797b#666062#4d4648#332d2f#1a1416
345° HSV / 1° LCh #fff7f9#e6dfe0#ccc6c7#b3abad#999394#80797b#666061#4d4648#332d2f#1a1415
350° HSV / 7° LCh #fff7f9#e6dfe0#ccc6c7#b3abad#999394#80797a#666061#4d4647#332d2e#1a1415
355° HSV / 13° LCh #fff7f8#e6dfdf#ccc6c6#b3abac#999192#80797a#666060#4d4647#332d2d#1a1414
360° HSV / 20° LCh #fff7f7#e6dfdf#ccc6c6#b3abab#999393#807979#666060#4d4646#332d2d#1a1414

Looking pretty good to me!

Color palettes classified as gray

Bootstrap grays ✅

  • gray-100: #f8f9fa
  • gray-200: #e9ecef
  • gray-300: #dee2e6
  • gray-400: #ced4da
  • gray-500: #adb5bd
  • gray-600: #6c757d
  • gray-700: #495057
  • gray-800: #343a40
  • gray-900: #212529

PANTONE cool grays ✅

  • 1C: #d9d9d6
  • 2C: #d0d0ce
  • 3C: #c8c9c7
  • 4C: #bbbcbc
  • 5C: #b1b3b3
  • 6C: #a7a8a9
  • 7C: #97999b
  • 8C: #888b8d
  • 9C: #75787b
  • 10C: #63666a
  • 11C: #53565a

PANTONE warm grays ✅

  • 1C: #d7d2cb
  • 2C: #cbc4bc
  • 3C: #bfb8af
  • 4C: #b6ada5
  • 5C: #aca39a
  • 6C: #a59c94
  • 7C: #968c83
  • 8C: #8c8279
  • 9C: #83786f
  • 10C: #796e65
  • 11C: #6e6259

CSS Colors ✅

  • black #000000
  • silver #c0c0c0
  • gray #808080
  • white #ffffff
  • dimgray #696969
  • dimgrey #696969
  • darkgray #a9a9a9
  • darkgrey #a9a9a9
  • grey #808080
  • lightgray #d3d3d3
  • lightgrey #d3d3d3
  • aliceblue #f0f8ff
  • gainsboro #dcdcdc
  • ghostwhite #f8f8ff
  • snow #fffafa
  • whitesmoke #f5f5f5
  • seashell #fff5ee
  • linen #faf0e6

(I slightly disagree with aliceblue but gonna leave it for now)

Color palettes not classified as gray

A lot of these are blues with chroma values way above what I’m willing to match with the current implementation of the function. I could treat blues as special, but I’m gonna leave it be for now.

CSS “Slate Gray” ❌

I would like to match these guys eventually.

  • lightslategray: #778899
  • lightslategrey: #778899
  • slategray: #708090
  • slategrey: #708090

Light CSS Colors ❌

  • azure #f0ffff
  • ivory #fffff0
  • floralwhite #fffaf0
  • honeydew #f0fff0
  • bisque #ffe4c4
  • blanchedalmond #ffebcd
  • burlywood #deb887
  • cornsilk #fff8dc
  • beige #f5f5dc
  • antiquewhite #faebd7

Tailwind CSS “grays” ❌

Matching these made my blue hue edge cases kinda bogus looking. I tend to agree with my function above. These look like blue colors to me: not gray.

  • 100: #F7FAFC
  • 200: #EDF2F7
  • 300: #E2E8F0
  • 400: #CBD5E0
  • 500: #A0AEC0
  • 600: #718096
  • 700: #4A5568
  • 800: #2D3748
  • 900: #1A202C

If you wanted to match these guys you would need to lax your tolerances for blue hues.

const hueToleranceStopsV2 = [
  [0, 3.1], // red
  [20, 3.1], // orange
  [61.8, 8], // orange
  [66, 7.49], // orange
  [75, 6.8], // orange
  [100, 2], // yellow-green
  [180, 2], // green
  [240, 5.4], // blue
  [262, 14], // blue
  [263, 14], // blue
  [290, 6.5],
  [310, 3.1], // red
  [360, 3.1],
];

Which looks like a much different curve:

tailwind hue curve

What is this useful for?

You could use this as a post-processing step to classify things as “gray”.

google images

roots/palette-webpack-plugin uses a simplified version of this technique to automatically sort colors in a color palette picker.


Further Reading