Every control needs more than one look: at rest, hovered, pressed, selected and disabled. Draw each state from the control's tokens with color functions such as darken(by:) and desaturate(by:), and each label stays readable in light and dark mode. Here's the travel app's booking button at rest, hovered, pressed, booked and sold out:
let label = brand.invertedForegroundPrimary // _50 in light mode, _1000 in dark
let fill = brand.invertedBackgroundTertiary // _650 and _250: label 5.11:1 and 10.20:1
let hovered = fill.darken() // _700 and _300: 6.10:1 and 9.00:1
let pressed = fill.darken(by: 2) // _750 and _350: 7.29:1 and 7.91:1
let booked = brand.backgroundTertiary // _200 and _700, with foregroundPrimary text
let soldOut = fill.desaturate(by: 1) // gray, as light as fill: 5.11:1 and 10.20:1These ratios hold in every hue, and rest, hover and pressed are exact stops a design spec can name. darken() makes the fill darker in both modes, the way a pressed button should look, and the label stays above 7:1 when pressed. desaturate(by:) keeps the fill's lightness, so Sold out stays readable.
Color a flight row#
A row has no fill at rest, so its states start from the page. Here it is at rest, hovered, selected and disabled:
struct FlightRow: View {
let theme: ProTheme
var isSelected = false
// Not private, so other files can use the memberwise init.
@State var isHovered = false
@Environment(\.isEnabled) var isEnabled
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("LIS → KIX")
.font(.headline)
Text("Oct 12 · 14h 20m")
.font(.subheadline)
.foregroundStyle(isEnabled ? theme.foregroundSecondary : disabledText)
}
Spacer()
Text("€620")
if isSelected {
Image(systemName: "checkmark")
}
}
.padding(12)
.foregroundStyle(isEnabled ? theme.foregroundPrimary : disabledText)
.background(fill)
.onHover { isHovered = $0 }
}
// Ten stops toward the page, to _500 in light mode and _550 in dark, then gray.
private var disabledText: Color {
theme.foregroundPrimary.soften(by: 10).desaturate(by: 1)
}
private var fill: Color {
if isSelected { return theme.backgroundSecondary.strengthen() } // _150 and _750
if isHovered && isEnabled { return theme.backgroundSecondary } // _100 and _800
return .clear
}
}
#Preview {
VStack(spacing: 0) {
FlightRow(theme: brand)
FlightRow(theme: brand, isHovered: true)
FlightRow(theme: brand, isSelected: true)
FlightRow(theme: brand)
.disabled(true)
}
.background(brand.backgroundPrimary)
}Selected goes one stop past hover, so a row under the pointer never looks selected. Strengthening a fill moves it toward the text, and a second stop would drop the subtitle to 4.48:1 in dark mode. The selected fill alone is under WCAG's 3:1 for non-text contrast, so the checkmark marks it.
Disabled text softens until it's 9 stops from the page, then loses its color, so the distance rule keeps it at 3:1 or more in any hue. WCAG exempts inactive controls from its contrast rule, but people read disabled text to find out what the control would do.
Put the button's states in a style#
struct BookButtonStyle: ButtonStyle {
var theme = brand
var isBooked = false
func makeBody(configuration: Configuration) -> some View {
BookButton(configuration: configuration, theme: theme, isBooked: isBooked)
}
}
private struct BookButton: View {
let configuration: ButtonStyleConfiguration
let theme: ProTheme
let isBooked: Bool
@Environment(\.isEnabled) private var isEnabled
@State private var isHovered = false
var body: some View {
configuration.label
.font(.headline)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.foregroundStyle(labelColor)
.background(fill)
.onHover { isHovered = $0 }
}
private var labelColor: Color {
isBooked ? theme.foregroundPrimary : theme.invertedForegroundPrimary
}
private var fill: Color {
let rest = theme.invertedBackgroundTertiary
if isBooked { return theme.backgroundTertiary }
if !isEnabled { return rest.desaturate(by: 1) }
if configuration.isPressed { return rest.darken(by: 2) }
if isHovered { return rest.darken() }
return rest
}
}
#Preview {
VStack {
Button("Book now") {}
Button("Booked") {}
.buttonStyle(BookButtonStyle(isBooked: true))
Button("Sold out") {}
.disabled(true)
}
.buttonStyle(BookButtonStyle())
}@State lives in views, not styles, so makeBody returns a small view that holds the hover state. Check booked first, since it changes the label too, then disabled, pressed and hover: a disabled button can be under the pointer, and a pressed one usually is. onHover(perform:) isn't available on tvOS or watchOS, so leave hover out of a style you share with them.
Next steps
- Setting up themes Recolor a view, a screen or your whole app from one value, with the same contrast in every hue.
- High contrast modes Respect Increase Contrast with one rule for every family: text and borders two stops stronger.
- .lighten(by:) Add highlights and lighter steps to any color, landing on an exact palette stop.
- How colors land on exact stops Derived colors land on exact stops, so you know their contrast before you run the app.