Color Harmonies
Isaac Newton, based on his observations of light interacting with prisms, constructed the first known color wheel. From there, many others built upon this work, sometimes with opposing ideas, but his original work is where color harmony got its start.
The original color wheel, while inspired by what was observed by light, was created based on experiments with pigments as well. In paints, red, yellow, and blue are often taught to be primary colors. This idea originates from Newton's work as through his experiments, he came to the conclusions that all colors could be made from red, yellow, and blue, and assumed this was true for light as well. While this isn't exactly 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.
>>> Wheel(Color('ryb', [1, 0, 0]).harmony('wheel', space='ryb'))
[color(--ryb 1 0 0 / 1), color(--ryb 1 0.5 0 / 1), color(--ryb 1 1 0 / 1), color(--ryb 0.5 1 0 / 1), color(--ryb 0 1 0 / 1), color(--ryb 0 1 0.5 / 1), color(--ryb 0 1 1 / 1), color(--ryb 0 0.5 1 / 1), color(--ryb 0 0 1 / 1), color(--ryb 0.5 0 1 / 1), color(--ryb 1 0 1 / 1), color(--ryb 1 0 0.5 / 1)]
The concept of color harmonies was formulated on the idea that colors, based on specific, relative position on the wheel, can form more pleasing color combinations.
Which Color Space is Best for Color Harmonies?
These days, there are many color spaces and models out there: subtractive models, additive models, perceptually uniform models, high dynamic range models, etc. But which space/model is the best for color harmonies?
The concept of primary colors stems from the idea that there are a set of pure colors from which all colors can be made from. The primary colors of electronic screens use red, green, and blue light to display its full gamut of color. Interestingly, electronic screens were modeled after the human eye that have three cones, each sensitive to wavelengths close to red, green, and blue. From what the three cones in the eye detect, our brain perceive all the various colors.
Paints and inks on the other hand can alter the light that comes back to our eye depending on the medium's light absorption and scattering properties. These physical properties of the medium can alter what light is reflected back to our eyes causing how our eyes perceive the mixing of colors in paint. This mixing behavior in pigments can seem different when compared to how pure light mixes.
The work that helped create the first color wheel was done with the limited paints that were available at the time, and influenced by the light scattering and absorption properties of those paints. Because of this, the original color wheel is built on the RYB color model which uses red, yellow, and blue as the primary colors. The question though, is would our theories on color harmony be different today if early theorists had a better understanding of light and color?
In reality, we can create a color wheel from any of the various color spaces out there and end up with different results based on how color is spaced and oriented within those spaces. As an example, we could create one directly from the sRGB color space. The sRGB space is an additive color space that was modeled on light behavior, so its primary colors are red, green, and blue. We can simply select the most red, green, and blue colors in its gamut, and transform them into a polar space such as HSL. Here, these colors are evenly spaced and if we mixed these colors evenly, and those colors evenly, we will get 12 colors whose hues are evenly spaced at 30˚.
>>> 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.
>>> Wheel(Color('red').harmony('wheel', space='srgb'))
[color(srgb 1 0 0 / 1), color(srgb 1 0.5 0 / 1), color(srgb 1 1 0 / 1), color(srgb 0.5 1 0 / 1), color(srgb 0 1 0 / 1), color(srgb 0 1 0.5 / 1), color(srgb 0 1 1 / 1), color(srgb 0 0.5 1 / 1), color(srgb 0 0 1 / 1), color(srgb 0.5 0 1 / 1), color(srgb 1 0 1 / 1), color(srgb 1 0 0.5 / 1)]
These results are different from the RYB color wheel we showed earlier, but are used to display all the colors you see on your screen right now. This is how light works when not scattered and absorbed by pigments, but does that mean that this color wheel yields better color harmonies?
We can transform the RGB color model into a subtractive color space, something similar to what printers use when applying ink to paper. Printers use something closer to an RYB model and use magenta, yellow, and cyan.
>>> Wheel(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 once again get very different results in the color wheel. The results are more similar to when we used sRGB, but the exact hues may be different and lightness is kept more uniform when we construct the wheel. But does that mean this model is better?
>>> Wheel(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)]
The truth is that what is better or even harmonious can be largely subjective.
Many artists swear by the limited, classical RYB color wheel, and others are fine with using the sRGB color wheel as it is easy to work with in CSS via the HSL color space, and models closer to how the eye works. Additionally, there may be some that prefer a perceptually uniform approach that aims 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 make no assertions that this is better than using any other color space. If you are from the world of paint, you may strongly dislike this default and prefer a classical RYB approach, or maybe you just want to use the familiar sRGB approach. 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
Regardless of what color space harmony()
operates in, it 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.
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.
>>> 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.
>>> 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.
>>> 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.
>>> 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.
>>> 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.
>>> 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.
>>> 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 wheel of evenly spaced colors of any size. From this, additional harmonies could be calculated. 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 of any size for any color.
>>> Wheel(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)]
>>> 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 a registered color space of 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] (think RGB). Afterwards, all color harmony calculations will use the specified color space unless overridden via the method's space
parameter.
>>> class Custom(Color):
... HARMONY = 'ryb'
...
>>> Steps(Custom('red').harmony('split'))
[color(--ryb 1 0 0 / 1), color(--ryb 0 0.5 1 / 1), color(--ryb 0 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.