Brian Bancroft

Automatically Set the Best Font Colour Using HSV

June 08,'19 | Tutorial

Colours are hard, right?

I have this side project. It's a portfolio for my wife. The content is managed in Contentful, and one of the things she controls is the theme colour of each of her projects. This controls both the background of the initial frame, as well as the colours of the following copy.

Initial Frame - Title

But what happens when the colour we choose for the background is too bright? The first lever I often reach for is another attribute which the content creator can modify, but this time, I see a candidate for colour theory!

It isn't just RGB

If you're in the spatial or print design worlds, you already are well-aware that there are different ways of expressing colours than RGB (or RGB hex). For printing, there is both CMYK (Cyan, Magenta, Yellow, Black... The K stands for "key", but everyone uses "black") as well as Pantone (a series of special inks used for printing). But another way of describing colours is done through HSV: Hue, Saturation, Value. You can learn more about it here.

hsv wheel

(File uploaded from the Wikimedia Commons)

The relevant thing is that brightness is controlled almost entirely by the "value" axis. The lower the V, the darker it is. And you can use any RGB value to get the V using math!

Obtaining That Value

The following is all Javascript. I'm using Node 8 for my work (lts/carbon), and I use the library "axios" for composition, although that is optional and you can run the functions one after the other. Imagine the function will always get the 6-digit hex CSS code, such as `#5FB6FF`. The first thing we want to do is convert that hex value to a base-10 number system from its base-16 origin:

const convertCSStoRGB = ({ color } = {}) => {
if (color.length === 7) {
return {
r: parseInt(color.substr(1, 2), 16),
g: parseInt(color.substr(3, 4), 16),
b: parseInt(color.substr(5, 6), 16),
} else if (color.length === 4) {
return {
r: parseInt(`${color.substr(1, 1)}${color.substr(1, 1)}`, 16),
g: parseInt(`${color.substr(2, 2)}${color.substr(2, 2)}`, 16),
b: parseInt(`${color.substr(3, 3)}${color.substr(3, 3)}`, 16),

Now that we've done that, we want to obtain the value. The formula for value is one half the sum of the maximum value from (r, g, or b) plus the minimum value from either (r, g, or b):

const getValueFromRGB = ({ r, g, b } = {}) => {
r = r / 255
g = g / 255
b = b / 255
const maxRGB = Math.max(r, Math.max(g, b))
const minRGB = Math.min(r, Math.min(g, b))
return (0.5 * (maxRGB + minRGB)) / 100

Finally, we want to determine what value makes a good place to ensure the secondary colours should be light or dark. I've decided on 0.9, but you can set a different one according to your tastes:

const lightOrDark = value => (value >= 0.9 ? 'dark' : 'light')

With all three of these functions, we are now able to compose:

import { compose } from 'ramda'
const textTheme = compose(
)(color: '#5FB6FF')

Walking through this partially, I get the value `0.9204313725490196` from the chosen colour, which gets me an end result of dark. These three small functions ensure that the content creator doesn't have to manually enter the colour combinations herself, but instead, when she enters the main theme colour, the secondary colour will follow suit.

Final Frame

And it works well! If you want to see it in action, have a look at and decide for yourself whether this can be used for the wonderful thing you're crunching!