Define colors with Hue, Saturation and Brightness rather than Red, Green and Blue properties

There are multiple ways to define colors in code. The most commonly used method is to specify values for the three primary colors - red, green and blue (RGB). This article explores the use of an alternative mechanism by specifying values for hue, saturation and brightness (HSB). The HSB properties can be used in a more intuitive way to create palettes with colors that work well together.


Related articles on HSB:

There are numerous resources online about color, I found Learn about Hue, Saturation and Brightness colours by Jonathan particularly useful, as well as The HSB Color System: A Practitioner's Primer by Erik Kennedy.



Color by RGB (Red, Green & Blue)

The most common way to define colors is to specify the Red, Green and Blue properties for the color. Each property can be a value from 0 to 255 in decimal format, but are commonly given in hexadecimal format so the color can be expressed in 6 characters. The Digital Colour Meter on Mac can be used to inspect any area on screen and give the RGB values for the selected color. A color palette can be created in SwiftUI to display possible colors.

 1struct RgbColorPaletteView: View {
 2    var body: some View {
 3        VStack(spacing:5) {
 4            VStack {
 5                HStack {
 6                    Text("Red")
 7                        .frame(width: cellWidth)
 8                    Text("Green")
 9                        .frame(width: cellWidth * 11.0)
10                    Text("Blue")
11                        .frame(width: cellWidth)
12                    Spacer()
13                }
14                HStack(spacing:1) {
15                    Spacer()
16                        .frame(width:cellWidth)
17                    ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { myGreen in
18                        Text("\(myGreen, specifier: "%0.1F")")
19                            .font(.footnote)
20                            .multilineTextAlignment(.center)
21                            .frame(width:cellWidth)
22                    }
23                    Spacer()
24                }
25            }
26                
27            ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { myRed in
28                HStack(spacing:1) {
29                    Text("\(myRed, specifier: "%0.1F")")
30                        .frame(width:cellWidth)
31                    ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { myGreen in
32                        HStack {
33                            VStack(spacing:1) {
34                                ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { myBlue in
35                                    Color(red: myRed, green: myGreen, blue: myBlue)
36                                }
37                                .frame(width:cellWidth)
38                            }
39                        }
40                    }
41                    VStack(spacing:13) {
42                        Text(myRed == 0.0 ? "0.0" : "")
43                        Image(systemName: "arrow.down")
44                            .foregroundColor(myRed == 0.0 ? Color.black : .clear)
45                        Text(myRed == 0.0 ? "1.0" : "")
46                    }
47                    .font(.footnote)
48                    .frame(width:cellWidth * 0.7)
49
50                    Spacer()
51                }
52            }
53            Spacer()
54        }
55        .padding()
56    }
57    
58    let cellWidth: CGFloat = 100
59}

Color palette with varying values for RGB (Red, Green & Blue)
Color palette with varying values for RGB (Red, Green & Blue)



Color by HSB (Hue, Saturation & Brightness)

The HSB color model is thought to align more closely with the way we think of color. Here is code to display a color palette by varying the values for Hue, Saturation and Brightness. Note that Hue is usually given a value in degrees representing the angle around a color circle with values between 0 and 360, SwiftUI uses values between 0.0 and 1.0 where 1.0 represents 360 degrees.

The first image shows the colors with the Brightness values from 1.0 to 0.5, as the brightness gets below 0.5, it can make the color palette look very dark. The second image shows the complete range.

 1struct HsbColorPaletteView: View {
 2    var body: some View {
 3        VStack(spacing:5) {
 4            VStack {
 5                HStack {
 6                    Text("Hue")
 7                        .frame(width: cellWidth)
 8                    Text("Saturation")
 9                        .frame(width: cellWidth * 11.0)
10                    Text("Brightness")
11                        .frame(width: cellWidth)
12                    Spacer()
13                }
14                HStack(spacing:1) {
15                    Spacer()
16                        .frame(width:cellWidth)
17                    ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { mySat in
18                        Text("\(mySat, specifier: "%0.1F")")
19                            .font(.footnote)
20                            .multilineTextAlignment(.center)
21                            .frame(width:cellWidth)
22                    }
23                    Spacer()
24                }
25            }
26                
27            ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { myHue in
28                HStack(spacing:1) {
29                    Text("\(myHue, specifier: "%0.1F")")
30                        .frame(width:cellWidth)
31                    ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { mySat in
32                        HStack {
33                            VStack(spacing:1) {
34                                //ForEach([1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.0], id: \.self) { myBright in
35                                ForEach([1.0, 0.9, 0.8, 0.7, 0.6, 0.5], id: \.self) { myBright in
36                                    Color(hue: myHue,
37                                          saturation: mySat,
38                                          brightness: myBright)
39                                }
40                                .frame(width:cellWidth)
41                            }
42                        }
43                    }
44                    VStack(spacing:13) {
45                        Text(myHue == 0.0 ? "1.0" : "")
46                        Image(systemName: "arrow.down")
47                            .foregroundColor(myHue == 0.0 ? Color.black : .clear)
48                        Text(myHue == 0.0 ? "0.5" : "")
49                    }
50                    .font(.footnote)
51                    .frame(width:cellWidth * 0.5)
52
53                    Spacer()
54                }
55            }
56            Spacer()
57        }
58        .padding()
59    }
60    
61    let cellWidth: CGFloat = 100
62}

Color palette with varying values for HSB (Hue, Saturation & Brightness) down to 0.5 brightness
Color palette with varying values for HSB (Hue, Saturation & Brightness) down to 0.5 brightness



Color palette with varying values for HSB (Hue, Saturation & Brightness) - lower brightness tend to be very dark
Color palette with varying values for HSB (Hue, Saturation & Brightness) - lower brightness tend to be very dark



Hue, Saturation and Brightness

  • Hue: represents the base color from red to violet through the colors of the rainbow.
  • Saturation: represents the intensity of the color. A saturation value of 0 will be white regardless of the specified Hue when the Brightness is 1.0.
  • Brightness: represents the brightness or lightness of the color. A brightness of 0 will be black regardless of the specified Hue.

The image below shows different colors based on increasing Hue in the first row. The second and third rows have the same Hue and show the effect of increasing Saturation and Brightness respectively. Greyscale colors can be defined by keeping the Saturation at 0 and adjusting the Brightness.

 1struct ChangeHsbView: View {
 2    var body: some View {
 3        ZStack {
 4            Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
 5                .edgesIgnoringSafeArea(.all)
 6            
 7            VStack() {
 8                VStack {
 9                    Text("Colors defined with")
10                    Text("Hue, Saturation & Brightness")
11                }
12                .font(.title)
13                .fontWeight(.bold)
14                
15                HueView()
16                    .frame(height:200)
17                
18                SatView()
19                    .frame(height:200)
20                
21                BrightView()
22                    .frame(height:200)
23                
24                
25                Spacer()
26            }
27            .padding(.horizontal, 150)
28        }
29    }
30}
 1struct HueView: View {
 2    var body: some View {
 3        VStack(alignment: .leading) {
 4            Text("1. Hue changes the Color")
 5                .font(.title2)
 6            HStack {
 7                ForEach([0.0, 0.2, 0.4, 0.6, 0.8, 1.0], id: \.self) { myHue in
 8                    VStack {
 9                        RoundedRectangle(cornerRadius: 20)
10                            .fill(Color(hue: myHue, saturation: 1.0, brightness: 1.0))
11                            .shadow(radius: 3, x:5, y:5)
12                        Text("H: \(myHue, specifier: "%0.2F")")
13                            .foregroundColor(.red)
14                        Text("S: 1.00")
15                        Text("B: 1.00")
16                    }
17                }
18            }
19        }
20        .padding(.vertical, 20)
21    }
22}
 1struct SatView: View {
 2    var body: some View {
 3        VStack(alignment: .leading) {
 4            Text("2. Saturation changes color Intensity")
 5                .font(.title2)
 6            
 7            HStack {
 8                ForEach([0.0, 0.2, 0.4, 0.6, 0.8, 1.0], id: \.self) { mySat in
 9                    VStack {
10                        RoundedRectangle(cornerRadius: 20)
11                            .fill(Color(hue: 0.75, saturation: mySat, brightness: 1.0))
12                            .shadow(radius: 3, x:5, y:5)
13                        Text("H: 0.75")
14                        Text("S: \(mySat, specifier: "%0.2F")")
15                            .foregroundColor(.red)
16                        Text("B: 1.00")
17                    }
18                }
19            }
20        }
21        .padding(.vertical, 20)
22    }
23}
 1struct BrightView: View {
 2    var body: some View {
 3        VStack(alignment: .leading) {
 4            Text("3. Brightness changes whiteness")
 5                .font(.title2)
 6            
 7            HStack {
 8                ForEach([0.0, 0.2, 0.4, 0.6, 0.8, 1.0], id: \.self) { myBright in
 9                    VStack {
10                        RoundedRectangle(cornerRadius: 20)
11                            .fill(Color(hue: 0.75, saturation: 1.00, brightness: myBright))
12                            .shadow(radius: 3, x:5, y:5)
13                        Text("H: 0.75")
14                        Text("S: 1.00")
15                        Text("B: \(myBright, specifier: "%0.2F")")
16                            .foregroundColor(.red)
17                    }
18                }
19            }
20        }
21        .padding(.vertical, 20)
22    }
23}

Color changes based on changing Hue, Saturation & Brightness properties
Color changes based on changing Hue, Saturation & Brightness properties



Color Wheel

In the HSB color model, Hue represents the base color and can be specified by the angle in degrees around a color circle where red is at the top and the colors follow the colors of the rainbow in a clockwise direction. SwiftUI uses values between 0 and 1 to represent the hue values from 0 to 360 degrees. The following code displays the Hue options in a circular slider similar to the slider in Create a circular slider in SwiftUI. Moving the slider selects the Hue and the different Saturation and Brightness values are displayed for the selected Hue.

 1struct ColorWheelView: View {
 2    @State private var hue: Double = 180.0
 3        
 4    var body: some View {
 5        ZStack {
 6            Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
 7                    .edgesIgnoringSafeArea(.all)
 8            
 9            VStack(spacing: 40) {
10                VStack(spacing: 5) {
11                    Text("Select Hue")
12                        .font(.system(size: 40, weight: .bold, design:.rounded))
13                    HStack {
14                        CircularSliderView(value: $hue, in: 0...360)
15                            .frame(width: 300, height: 300)
16                    }
17                }
18                
19                VStack(spacing: 5) {
20                    Text("Selected Hue with decreasing Saturation")
21                        .font(.system(size: 40, weight: .bold, design:.rounded))
22                    HStack() {
23                        ForEach([1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.0], id: \.self) { mySat in
24                            VStack {
25                                RoundedRectangle(cornerRadius: 10)
26                                    .fill(Color(hue: hue/360,
27                                                saturation: mySat,
28                                                brightness: 1.0))
29                                    .frame(height:100)
30                                    .overlay {
31                                        Text("\(mySat, specifier: "%0.1F")")
32                                            .font(.system(size: 30))
33                                }
34                                Text("H: \(hue/360, specifier: "%0.2F")")
35                                Text("S: \(mySat, specifier: "%0.2F")")
36                                Text("B: 1.00")
37                            }
38                        }
39                    }
40                    .padding(.horizontal, 100)
41                }
42                            
43                VStack(spacing: 5) {
44                    Text("Selected Hue with decreasing Brightness")
45                        .font(.system(size: 40, weight: .bold, design:.rounded))
46                    HStack() {
47                        ForEach([1.0, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.0], id: \.self) { myBright in
48                            VStack {
49                                RoundedRectangle(cornerRadius: 10)
50                                    .fill(Color(hue: hue/360,
51                                                saturation: 1.0,
52                                                brightness: myBright))
53                                    .frame(height:100)
54                                    .overlay {
55                                        Text("\(myBright, specifier: "%0.1F")")
56                                            .font(.system(size: 30))
57                                            .foregroundColor(myBright > 0.5 ? Color.black : .white)
58                                }
59                                Text("H: \(hue/360, specifier: "%0.2F")")
60                                Text("S: 1.00")
61                                Text("B: \(myBright, specifier: "%0.2F")")
62                            }
63                        }
64                    }
65                    .padding(.horizontal, 100)
66                }
67                
68                Spacer()
69            }
70        }
71    }
72}
 1struct CircularSliderView: View {
 2    @Binding var progress: Double
 3    
 4    @State private var rotationAngle = Angle(degrees: 0)
 5    private var minValue = 0.0
 6    private var maxValue = 1.0
 7    
 8    init(value progress: Binding<Double>, in bounds: ClosedRange<Int> = 0...1) {
 9        self._progress = progress
10        
11        self.minValue = Double(bounds.first ?? 0)
12        self.maxValue = Double(bounds.last ?? 1)
13        self.rotationAngle = Angle(degrees: progressFraction * 360.0)
14    }
15    
16    var body: some View {
17        GeometryReader { gr in
18            let radius = (min(gr.size.width, gr.size.height) / 2.0) * 0.9
19            let sliderWidth = radius * 0.3
20            
21            VStack(spacing:0) {
22                ZStack {
23                    Circle()
24                        .strokeBorder(hueAngularGradient,
25                                      style: StrokeStyle(lineWidth: sliderWidth))
26                        .rotationEffect(Angle(degrees: -90))
27                        .overlay() {
28                            Text("\(progress, specifier: "%.0f")")
29                                .font(.system(size: radius * 0.5, weight: .bold, design:.rounded))
30                        }
31                    Circle()
32                        .fill(Color.white)
33                        .shadow(radius: (sliderWidth * 0.3))
34                        .frame(width: sliderWidth, height: sliderWidth)
35                        .offset(y: -(radius - (sliderWidth * 0.5)))
36                        .rotationEffect(rotationAngle)
37                        .gesture(
38                            DragGesture(minimumDistance: 0.0)
39                                .onChanged() { value in
40                                    changeAngle(location: value.location)
41                                }
42                        )
43                }
44                .frame(width: radius * 2.0, height: radius * 2.0, alignment: .center)
45                .padding(radius * 0.1)
46            }
47            .onAppear {
48                self.rotationAngle = Angle(degrees: progressFraction * 360.0)
49            }
50        }
51    }
52    
53    private var progressFraction: Double {
54        return ((progress - minValue) / (maxValue - minValue))
55    }
56    
57    private func changeAngle(location: CGPoint) {
58        // Create a Vector for the location (reversing the y-coordinate system on iOS)
59        let vector = CGVector(dx: location.x, dy: -location.y)
60        
61        // Calculate the angle of the vector
62        let angleRadians = atan2(vector.dx, vector.dy)
63        
64        // Convert the angle to a range from 0 to 360 (rather than having negative angles)
65        let positiveAngle = angleRadians < 0.0 ? angleRadians + (2.0 * .pi) : angleRadians
66        
67        // Update slider progress value based on angle
68        progress = ((positiveAngle / (2.0 * .pi)) * (maxValue - minValue)) + minValue
69        rotationAngle = Angle(radians: positiveAngle)
70    }
71    
72    let hueAngularGradient = AngularGradient(
73        gradient: Gradient(colors: [
74            Color(hue: 0.0, saturation: 1.0, brightness: 1.0),
75            Color(hue: 0.1, saturation: 1.0, brightness: 1.0),
76            Color(hue: 0.2, saturation: 1.0, brightness: 1.0),
77            Color(hue: 0.3, saturation: 1.0, brightness: 1.0),
78            Color(hue: 0.4, saturation: 1.0, brightness: 1.0),
79            Color(hue: 0.5, saturation: 1.0, brightness: 1.0),
80            Color(hue: 0.6, saturation: 1.0, brightness: 1.0),
81            Color(hue: 0.7, saturation: 1.0, brightness: 1.0),
82            Color(hue: 0.8, saturation: 1.0, brightness: 1.0),
83            Color(hue: 0.9, saturation: 1.0, brightness: 1.0),
84            Color(hue: 1.0, saturation: 1.0, brightness: 1.0),
85        ]),
86        center: .center,
87        startAngle: .degrees(0),
88        endAngle: .degrees(360.0))
89}

Color wheel to select Hue and see variations of Saturation & Brightness
Color wheel to select Hue and see variations of Saturation & Brightness



Color wheel showing variations of Saturation & Brightness for each Hue

Color wheel showing variations of Saturation & Brightness for each Hue



Matching Colors

One of the advantages of using HSB for colors is the ease of finding suitable colors that work well together. The first option could be to use the same Hue and change either the Saturation or the Brightness. This can work really well for gradients moving from a lower saturation to a higher saturation or for a darker border or frame using the same hue. The next option is to use adjacent or analogous colors by varying the hue by couple of degrees.

Complementary colors are colors that are opposite each other on the color wheel. These colors provide great contrast and work well together even before taking into account use of saturation and brightness. There is also a triadic color scheme, where the three colors are distributed evenly around the color wheel. These three colors work well together, but some care needs to be taken that the views don't appear overwhelming. It is usually better to have one dominant color.

A ColorModel is defined to create the various color schemes as the selected Hue is changed. The MatchingColorView shows the different sets of matching colors as the hue is changed using the circular slider.


model

 1struct ColorModel {
 2    var hueDegrees: Double
 3    private var sat: Double
 4    private var bright: Double
 5
 6    let totalDegrees = 360.0
 7    
 8    init(hueDegrees: Double, sat: Double, bright: Double) {
 9        self.hueDegrees = hueDegrees
10        self.sat = sat
11        self.bright = bright
12    }
13    
14    init() {
15        self.init(hueDegrees: 0, sat: 1.0, bright: 1.0)
16    }
17    
18    var hueDouble: Double {
19        return Double(self.hueDegrees) / totalDegrees
20    }
21
22    var color: Color {
23        return Color(hue: hueDouble, saturation: sat, brightness: bright)
24    }
25    
26    // Monochromatic
27    var monochromaticColors: [Color] {
28        return [
29            Color(hue: hueDouble, saturation: sat, brightness: bright),
30            Color(hue: hueDouble, saturation: (sat * 0.8), brightness: bright),
31            Color(hue: hueDouble, saturation: (sat * 0.6), brightness: bright),
32            Color(hue: hueDouble, saturation: (sat * 0.4), brightness: bright)
33        ]
34    }
35    
36    private func adjustHue(_ value: Double, percent adjustment: Double) -> Double {
37        return Double((Int((value * 100) + adjustment)) % 100) / 100.0
38    }
39    
40    // Analogous
41    var analogousColors: [Color] {
42        let hue1 = adjustHue(hueDouble, percent: 4)
43        let hue2 = adjustHue(hueDouble, percent: -4)
44        return [
45            Color(hue: hueDouble, saturation: sat, brightness: bright),
46            Color(hue: hue1, saturation: sat, brightness: bright),
47            Color(hue: hue2, saturation: sat, brightness: bright)
48        ]
49    }
50
51    // Complementary
52    var complementaryColors: [Color] {
53        let hue1 = adjustHue(hueDouble, percent: 50)
54        return [
55            Color(hue: hueDouble, saturation: sat, brightness: bright),
56            Color(hue: hue1, saturation: sat, brightness: bright)
57        ]
58    }
59    
60    // Triadic
61    var triadicColors: [Color] {
62        let hue1 = adjustHue(hueDouble, percent: 33.33)
63        let hue2 = adjustHue(hueDouble, percent: 66.66)
64        return [
65            Color(hue: hueDouble, saturation: sat, brightness: bright),
66            Color(hue: hue1, saturation: sat, brightness: bright),
67            Color(hue: hue2, saturation: sat, brightness: bright)
68        ]
69    }
70}

viewmodel

 1class ColorViewModel: ObservableObject {
 2    @Published var colorModel: ColorModel
 3    
 4    init() {
 5        self.colorModel = ColorModel()
 6    }
 7    
 8    var selectedColor: Color {
 9        colorModel.color
10    }
11    
12    var monochromaticColors: [Color] {
13        return colorModel.monochromaticColors
14    }
15    
16    var analogousColors: [Color] {
17        return colorModel.analogousColors
18    }
19    
20    var complementaryColors: [Color] {
21        return colorModel.complementaryColors
22    }
23
24    var triadicColors: [Color] {
25        return colorModel.triadicColors
26    }
27}

view

 1struct MatchingColorView: View {
 2    @ObservedObject private var colorVm = ColorViewModel()
 3    
 4    var body: some View {
 5        ZStack {
 6            Color(hue: 0.58, saturation: 0.17, brightness: 1.0)
 7                .edgesIgnoringSafeArea(.all)
 8            
 9            VStack(spacing:30) {
10                HStack {
11                    VStack(spacing: 5) {
12                        Text("Select Hue")
13                            .font(.system(size: 40, weight: .bold, design:.rounded))
14                        HStack {
15                            CircularSliderView(value: $colorVm.colorModel.hueDegrees, in: 0...360)
16                                .frame(width: 300, height: 300)
17                        }
18                    }
19                    
20                    Spacer().frame(width: 200)
21                    
22                    VStack {
23                        RoundedRectangle(cornerRadius: 20)
24                            .fill(colorVm.selectedColor)
25                            .frame(width: 300, height: 250, alignment: .center)
26                        Text("Selected Color")
27                            .font(.system(size: 30, weight: .bold, design:.rounded))
28                    }
29                }
30                
31                VStack(spacing:20) {
32                    HStack {
33                        Text("Monochromatic")
34                            .frame(width: 300, alignment: .trailing)
35                        ForEach(colorVm.monochromaticColors, id: \.self) { col in
36                            RoundedRectangle(cornerRadius: 20)
37                                .fill(col)
38                        }
39                    }
40                    HStack {
41                        Text("Analogous")
42                            .frame(width: 300, alignment: .trailing)
43                        ForEach(colorVm.analogousColors, id: \.self) { col in
44                            RoundedRectangle(cornerRadius: 20)
45                                .fill(col)
46                        }
47                    }
48                    HStack {
49                        Text("Complementary")
50                            .frame(width: 300, alignment: .trailing)
51                        ForEach(colorVm.complementaryColors, id: \.self) { col in
52                            RoundedRectangle(cornerRadius: 20)
53                                .fill(col)
54                        }
55                    }
56                    HStack {
57                        Text("Triadic")
58                            .frame(width: 300, alignment: .trailing)
59                        ForEach(colorVm.triadicColors, id: \.self) { col in
60                            RoundedRectangle(cornerRadius: 20)
61                                .fill(col)
62                        }
63                    }
64                }
65                .padding(.horizontal, 100)
66                .font(.system(size: 30, weight: .bold, design:.rounded))
67            }
68            .padding(50)
69        }
70    }
71}

Select a color to see matching colors in SwiftUI
Select a color to see matching colors in SwiftUI



Color wheel showing matching colors for each Hue

Color wheel showing matching colors for each Hue




Conclusion

I find that defining colors with HSB is a more intuitive way of defining colors. There is nothing wrong with using RGB color model and if you have RGB values, then use them. However, it can sometimes be difficult to identify colors that go well together when starting with RGB values. It can be easier to stick with the same hue and adjust either saturation or brightness to add some variation to screen layout without changing color. It is also easier to identify adjacent colors or complementary colors with HSB than with RGB.

The code for ColorSelectionHsbApp is available on GitHub.