Skip to content

Color Harmonies

In color theory, color harmony refers to the property that certain aesthetically pleasing color combinations have. Modern day color theory probably starts with the first color wheel created by Isaac Newton. Based on his observations of light with prisms, he formed one the first color wheels. From there, many others built upon this work, sometimes with opposing ideas.

The original color wheel, while inspired by what was observed by light, was created based on experiments with pigments as well. As most know, in paint, red, yellow, and blue are considered primary colors. Newton thought this translated to light as well and stated they were also the primary colors of light. While this isn't actually true, his work was very important in reshaping how people viewed color.

Over time, the color wheel was refined. The traditional model, which we will call an RYB color model, defined 12 colors that made up the wheel: the primary colors, the secondary colors, and the tertiary colors. The secondary colors are created by evenly mixing the primary colors, and the tertiary colors are created by evenly mixing those primary colors with the secondary colors.

The idea of color harmonies originates from the idea that colors, based on their relative position on the wheel, can form more pleasing color combinations.

Which Color Space is Best for Color Harmonies?

As we know, these days, there are many color spaces out there: subtractive models, additive models, perceptually uniform models, high dynamic range models, etc. Many color spaces trying to solve specific issues based on the knowledge at the time.

It should be noted, that the idea of primary colors stems from the idea that there are a set of pure colors from which all colors can be made from. If you've spent any time with paint, you will know that not all colors can be made from red, yellow, and blue. There are colors like cyan and magenta that cannot be made with the traditional primary colors. The early work that helped create the first color wheels was done with the limited paints that was available at the time, and the color harmony concepts were built upon the early RYB color model.

In modern TVs and monitors, the RYB color model is not used. Paint has subtractive properties, but light has additive properties. Electronic screens create all their colors with light based methods that mix red, green, and blue lights. In addition, the human eye perceives colors using red, green, and blue light as well. This is As far as light is concerned, the primary colors are red, green, and blue.

In reality, we could create a color wheel from any of the various color spaces out there and end up with different results. If we were to compose a color wheel based on the common sRGB color space, we could base it off the 3 primary colors of light. Starting with red (0˚), we could extract the colors at evenly spaced degrees, 30˚ to be exact. This would give us our 12 colors for the sRGB color space.

>>> Steps([Color('hsl', [x, 1, 0.5]) for x in range(0, 360, 30)])
[color(--hsl 0 1 0.5 / 1), color(--hsl 30 1 0.5 / 1), color(--hsl 60 1 0.5 / 1), color(--hsl 90 1 0.5 / 1), color(--hsl 120 1 0.5 / 1), color(--hsl 150 1 0.5 / 1), color(--hsl 180 1 0.5 / 1), color(--hsl 210 1 0.5 / 1), color(--hsl 240 1 0.5 / 1), color(--hsl 270 1 0.5 / 1), color(--hsl 300 1 0.5 / 1), color(--hsl 330 1 0.5 / 1)]

From this we can construct an sRGB color wheel.

This is different from the RYB color wheel we showed earlier, and more accurate in relation to how light works, but does it yield better harmonies for colors?

The sRGB color space is additive, just like light, but pigments are subtractive. We can use CMY to generate a subtractive wheel with a far greater range that red, green blue creates by use magenta, yellow, and cyan. But does this create better harmonies?

>>> Steps(Color('magenta').harmony('wheel', space='cmy'))
[color(--cmy 0 1 0 / 1), color(--cmy 0 1 0.5 / 1), color(--cmy 0 1 1 / 1), color(--cmy 0 0.5 1 / 1), color(--cmy 0 0 1 / 1), color(--cmy 0.5 0 1 / 1), color(--cmy 1 0 1 / 1), color(--cmy 1 0 0.5 / 1), color(--cmy 1 0 0 / 1), color(--cmy 1 0.5 0 / 1), color(--cmy 1 1 0 / 1), color(--cmy 0.5 1 0 / 1)]

If we were to select the perceptually uniform OkLCh color space, and seed it with red's lightness and chroma, we'd get the wheel below.

>>> Steps(Color('red').harmony('wheel', space='oklch'))
[color(--oklch 0.62796 0.25768 29.234 / 1), color(--oklch 0.62796 0.25768 59.234 / 1), color(--oklch 0.62796 0.25768 89.234 / 1), color(--oklch 0.62796 0.25768 119.23 / 1), color(--oklch 0.62796 0.25768 149.23 / 1), color(--oklch 0.62796 0.25768 179.23 / 1), color(--oklch 0.62796 0.25768 209.23 / 1), color(--oklch 0.62796 0.25768 239.23 / 1), color(--oklch 0.62796 0.25768 269.23 / 1), color(--oklch 0.62796 0.25768 299.23 / 1), color(--oklch 0.62796 0.25768 329.23 / 1), color(--oklch 0.62796 0.25768 359.23 / 1)]

This produces colors with visually more uniform lightness, does that mean these are better?

The truth is, what is better or even harmonious can be largely subjective, and everyone has reasons for selecting certain color spaces for a specific task.

Many artists swear by the limited, classical color wheel, others are fine with using the RGB color wheel as it is easy to work with in CSS via the HSL color space, and there are still others that are more interested in perceptually uniform color spaces that aim for more consistent hues and predictable lightness.

As far as ColorAide is concerned, we've chosen to use OkLCh as the color space in which we work in. This is based mainly on the fact that it keeps hue more consistent than some other options, and it allows us to support a wider gamut than options like HSL.

>>> Steps(Color.steps(['black', 'blue', 'white'], steps=11, space='oklch'))
[color(--oklch 0 0 264.05 / 1), color(--oklch 0.0904 0.06264 264.05 / 1), color(--oklch 0.18081 0.12529 264.05 / 1), color(--oklch 0.27121 0.18793 264.05 / 1), color(--oklch 0.36161 0.25057 264.05 / 1), color(--oklch 0.45201 0.31321 264.05 / 1), color(--oklch 0.56161 0.25057 264.05 / 1), color(--oklch 0.67121 0.18793 264.05 / 1), color(--oklch 0.78081 0.12529 264.05 / 1), color(--oklch 0.8904 0.06264 264.05 / 1), color(--oklch 1 0 264.05 / 1)]
>>> Steps(Color.steps(['black', 'blue', 'white'], steps=11, space='hsl'))
[color(--hsl 240 0 0 / 1), color(--hsl 240 0.2 0.1 / 1), color(--hsl 240 0.4 0.2 / 1), color(--hsl 240 0.6 0.3 / 1), color(--hsl 240 0.8 0.4 / 1), color(--hsl 240 1 0.5 / 1), color(--hsl 240 0.8 0.6 / 1), color(--hsl 240 0.6 0.7 / 1), color(--hsl 240 0.4 0.8 / 1), color(--hsl 240 0.2 0.9 / 1), color(--hsl 240 0 1 / 1)]
>>> Steps(Color.steps(['black', 'blue', 'white'], steps=11, space='lch'))
[color(--lch 0 0 301.36 / 1), color(--lch 5.9137 26.24 301.36 / 1), color(--lch 11.827 52.481 301.36 / 1), color(--lch 17.741 78.721 301.36 / 1), color(--lch 23.655 104.96 301.36 / 1), color(--lch 29.568 131.2 301.36 / 1), color(--lch 43.655 104.96 301.36 / 1), color(--lch 57.741 78.721 301.36 / 1), color(--lch 71.827 52.481 301.36 / 1), color(--lch 85.914 26.24 301.36 / 1), color(--lch 100 0 301.36 / 1)]

While OkLCh is the default, we understand that there are many reasons to use other spaces, so use what you like, we won't judge 😄. If you are a color theory purist, you can use the classical RYB model.

>>> Steps(Color('red').harmony('complement'))
[color(--oklch 0.62796 0.25768 29.234 / 1), color(--oklch 0.62796 0.25768 209.23 / 1)]
>>> Steps(Color('ryb', [1, 0, 0]).harmony('complement', space='ryb'))
[color(--ryb 1 0 0 / 1), color(--ryb 0 1 1 / 1)]

RYB Model

The RYB model has a more limited color gamut than sRGB as the red, yellow and blue primaries cannot make all colors. Additionally, the red, yellow, and blue primaries are not the same as the ones in sRGB, so when using RYB to generate harmonies, make sure you are working directly within RYB to ensure you are not out of gamut.

Tip

harmony() can output the results in any color space you need by setting out_space.

>>> Steps(Color('red').harmony('complement', out_space='srgb'))
[color(srgb 1 0 0 / 1), color(srgb -0.56631 0.66342 0.85808 / 1)]

Supported Harmonies

ColorAide currently supports 7 theorized color harmonies: monochromatic, complementary, split complementary, analogous, triadic, square, and rectangular. By default, all color harmonies are calculated with the perceptually uniform OkLCh color space, but other color spaces can be used if desired.

OkLCh Color Wheel

Monochromatic

The monochromatic harmony pairs various tints and shades by mixing white and black respectively with the target color to create pleasing color schemes. The number of tints and shades that are created is determined by color distance between white and black via ∆E2000.

Then number of colors returned by the monochromatic harmony can be controlled via the count parameter, 5 being the default.

Harmony Monochromatic

>>> Steps(Color('red').harmony('mono'))
[color(--oklch 0.20932 0.08589 29.234 / 1), color(--oklch 0.41864 0.17179 29.234 / 1), color(--oklch 0.62796 0.25768 29.234 / 1), color(--oklch 0.75197 0.17179 29.234 / 1), color(--oklch 0.87599 0.08589 29.234 / 1)]
>>> Steps(Color('red').harmony('mono', count=8))
[color(--oklch 0.12559 0.05154 29.234 / 1), color(--oklch 0.25118 0.10307 29.234 / 1), color(--oklch 0.37677 0.15461 29.234 / 1), color(--oklch 0.50236 0.20615 29.234 / 1), color(--oklch 0.62796 0.25768 29.234 / 1), color(--oklch 0.72097 0.19326 29.234 / 1), color(--oklch 0.81398 0.12884 29.234 / 1), color(--oklch 0.90699 0.06442 29.234 / 1)]

Achromatic Colors

Pure white and black will not be included in a monochromatic color harmony unless the color is achromatic.

New 3.3

The count parameter is new in 3.3.

Complementary

Complementary harmonies use a dyad of colors at opposite ends of the color wheel.

Harmony Complementary

>>> Steps(Color('red').harmony('complement'))
[color(--oklch 0.62796 0.25768 29.234 / 1), color(--oklch 0.62796 0.25768 209.23 / 1)]

Split Complementary

Split Complementary is similar to complementary, but actually uses a triad of colors. Instead of just choosing one complement, it splits and chooses two colors on the opposite side that are close, but not adjacent.

Harmony Split Complementary

>>> Steps(Color('red').harmony('split'))
[color(--oklch 0.62796 0.25768 29.234 / 1), color(--oklch 0.62796 0.25768 239.23 / 1), color(--oklch 0.62796 0.25768 -180.77 / 1)]

Analogous

Analogous harmonies consists of 3 adjacent colors.

Harmony Analogous

>>> Steps(Color('red').harmony('analogous'))
[color(--oklch 0.62796 0.25768 29.234 / 1), color(--oklch 0.62796 0.25768 59.234 / 1), color(--oklch 0.62796 0.25768 -0.76612 / 1)]

Triadic

Triadic draws an equilateral triangle between 3 colors on the color wheel. For instance, the primary colors have triadic harmony.

Harmony Triadic

>>> Steps(Color('red').harmony('triad'))
[color(--oklch 0.62796 0.25768 29.234 / 1), color(--oklch 0.62796 0.25768 149.23 / 1), color(--oklch 0.62796 0.25768 269.23 / 1)]

Tetradic Square

Tetradic color harmonies refer to a group of four colors. One tetradic color harmony can be found by drawing a square between four colors on the color wheel.

Harmony Tetradic

>>> Steps(Color('red').harmony('square'))
[color(--oklch 0.62796 0.25768 29.234 / 1), color(--oklch 0.62796 0.25768 119.23 / 1), color(--oklch 0.62796 0.25768 209.23 / 1), color(--oklch 0.62796 0.25768 299.23 / 1)]

Tetradic Rectangular

The rectangular tetradic harmony is very similar to the square tetradic harmony except that it draws a rectangle between four colors instead of a square.

Harmony Tetradic Rectangular

>>> Steps(Color('red').harmony('rectangle'))
[color(--oklch 0.62796 0.25768 29.234 / 1), color(--oklch 0.62796 0.25768 59.234 / 1), color(--oklch 0.62796 0.25768 209.23 / 1), color(--oklch 0.62796 0.25768 239.23 / 1)]

Others

If you have a particular configuration that you are after that is not covered with the default harmonies, you can use harmony to calculate your own via wheel. The wheel harmony that can generate a color wheel of any size. Simply use a color to seed the wheel, specify the space in which to generate the wheel, and optionally, provide the desired number of colors in the color wheel via the count argument. With this, we can generate a wheel for any color (assuming the color space can properly handle the color). We can even generate an extended color wheel if so desired. This can allow you to select hues at any interval you need.

>>> Steps(Color('ryb', [1, 0, 0]).harmony('wheel', space='ryb', count=48))
[color(--ryb 1 0 0 / 1), color(--ryb 1 0.125 0 / 1), color(--ryb 1 0.25 0 / 1), color(--ryb 1 0.375 0 / 1), color(--ryb 1 0.5 0 / 1), color(--ryb 1 0.625 0 / 1), color(--ryb 1 0.75 0 / 1), color(--ryb 1 0.875 0 / 1), color(--ryb 1 1 0 / 1), color(--ryb 0.875 1 0 / 1), color(--ryb 0.75 1 0 / 1), color(--ryb 0.625 1 0 / 1), color(--ryb 0.5 1 0 / 1), color(--ryb 0.375 1 0 / 1), color(--ryb 0.25 1 0 / 1), color(--ryb 0.125 1 0 / 1), color(--ryb 0 1 0 / 1), color(--ryb 0 1 0.125 / 1), color(--ryb 0 1 0.25 / 1), color(--ryb 0 1 0.375 / 1), color(--ryb 0 1 0.5 / 1), color(--ryb 0 1 0.625 / 1), color(--ryb 0 1 0.75 / 1), color(--ryb 0 1 0.875 / 1), color(--ryb 0 1 1 / 1), color(--ryb 0 0.875 1 / 1), color(--ryb 0 0.75 1 / 1), color(--ryb 0 0.625 1 / 1), color(--ryb 0 0.5 1 / 1), color(--ryb 0 0.375 1 / 1), color(--ryb 0 0.25 1 / 1), color(--ryb 0 0.125 1 / 1), color(--ryb 0 0 1 / 1), color(--ryb 0.125 0 1 / 1), color(--ryb 0.25 0 1 / 1), color(--ryb 0.375 0 1 / 1), color(--ryb 0.5 0 1 / 1), color(--ryb 0.625 0 1 / 1), color(--ryb 0.75 0 1 / 1), color(--ryb 0.875 0 1 / 1), color(--ryb 1 0 1 / 1), color(--ryb 1 0 0.875 / 1), color(--ryb 1 0 0.75 / 1), color(--ryb 1 0 0.625 / 1), color(--ryb 1 0 0.5 / 1), color(--ryb 1 0 0.375 / 1), color(--ryb 1 0 0.25 / 1), color(--ryb 1 0 0.125 / 1)]

Changing the Default Harmony Color Space

New 2.7

Non-cylindrical space support was added in 2.7.

If you'd like to change the Color() class's default harmony color space, it can be done with class override. Simply derive a new Color() class from the original and override the HARMONY property with the name of a suitable color space. Color spaces must be either a cylindrical space, a Lab-like color space, or what we will call a regular, rectangular space. By "regular" we mean a normal 3 channel color space usually with a range of [0, 1]. Afterwards, all color harmony calculations will use the specified color space unless overridden via the method's space parameter.

>>> class Custom(Color):
...     HARMONY = 'hsl'
... 
>>> Steps(Custom('red').harmony('split'))
[color(--hsl 0 1 0.5 / 1), color(--hsl 210 1 0.5 / 1), color(--hsl -210 1 0.5 / 1)]

Warning

Remember that every color space is different. Some may rotate hues in a different direction and some may just not be very compatible for extracting harmonies from.

Additionally, a color space may not handle colors beyond its gamut well, for such color spaces, it is important to work within that spaces gamut opposed to picking colors outside of the gamut and relying on gamut mapping.