How do dynamic colours work, how do you give a colour dark and high-contrast variants, and when does a colour stop adapting?
Predict first
How do the four variants measure, and what does a cgColor capture?
import UIKit
func parts(_ c: UIColor, _ tc: UITraitCollection) -> (Double, String) {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
c.resolvedColor(with: tc).getRed(&r, green: &g, blue: &b, alpha: &a)
func f(_ x: CGFloat) -> Double { let v = Double(x); return v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) }
return (0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b), String(format: "#%02X%02X%02X", Int((r * 255).rounded()), Int((g * 255).rounded()), Int((b * 255).rounded())))
}
@MainActor func run() {
func rgb(_ r: Double, _ g: Double, _ b: Double) -> UIColor { UIColor(red: r / 255, green: g / 255, blue: b / 255, alpha: 1) }
let brand = UIColor { tc in
switch (tc.userInterfaceStyle == .dark, tc.accessibilityContrast == .high) {
case (false, false): return rgb(0x2F, 0x80, 0xED)
case (false, true): return rgb(0x0A, 0x4C, 0xA8)
case (true, false): return rgb(0x5A, 0xA0, 0xFF)
case (true, true): return rgb(0x9C, 0xC7, 0xFF)
}
}
for (name, style, con) in [("light", UIUserInterfaceStyle.light, UIAccessibilityContrast.normal), ("light+hc", .light, .high), ("dark", .dark, .normal), ("dark+hc", .dark, .high)] {
let tc = UITraitCollection(traitsFrom: [UITraitCollection(userInterfaceStyle: style), UITraitCollection(accessibilityContrast: con)])
let (l1, h1) = parts(brand, tc), (l2, h2) = parts(.systemBackground, tc)
let ratio = (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
print(name, h1, "on", h2, String(format: "%.2f", ratio), ratio >= 4.5 ? "AA" : "fail")
}
print("cgColor is resolved once, in the current (light) traits:", brand.cgColor.components!.map { String(format: "%.2f", $0) })
}
MainActor.assumeIsolated { run() }
- light #2F80ED on #FFFFFF 3.87 fail / light+hc … 8.04 AA / dark … 7.91 AA / dark+hc … 12.03 AA / cgColor fixed in light
- All four pass
- The cgColor follows the appearance
Mental model. A chameleon is not one colour but a rule. A CGColor is a photograph of the chameleon taken at one moment.
A UIColor created with a closure (UIColor { traits in … }) or a named colour from an asset catalog is a dynamic colour: it holds several values and picks one when it is resolved against a trait collection, here the appearance (userInterfaceStyle) and accessibilityContrast. The check defines one brand blue with four variants and measures each against systemBackground with the WCAG formula (Q471): the standard light blue #2F80ED on white is 3.87 and fails for body text, its increased-contrast variant #0A4CA8 reaches 8.04, and both dark variants pass. The lesson is that a colour can pass in one of the four combinations and fail in another, so test all four, not only the one on your design file. System colours already do this: systemBlue resolves to a different value when increased contrast is on (the check prints #0088FF and #1E6EF4 on this simulator; exact values change between OS versions).
Adaptation happens at resolution time. UIKit resolves dynamic colours for you when it draws a view in its own trait environment, but a CGColor taken from one is a fixed value for the traits current at the moment you asked, which is why the final line shows the light numbers: assign layer.borderColor again in traitCollectionDidChange (or the newer trait registration APIs), or call resolvedColor(with:) explicitly. The same applies to colours captured in images, shadows and anything cached. In SwiftUI the Color asset or semantic colours adapt, and \.colorSchemeContrast tells you the contrast setting. Define variants once in the asset catalog (Any, Dark, and the High Contrast checkboxes) and use names, not hex values, in code (Q470).
Resolve against every trait combination; a CGColor is a snapshot.
Common traps
- Checking contrast only in light mode. The same brand colour can fail in one appearance or contrast combination.
- Storing
cgColorin a layer and expecting it to follow Dark Mode. It is resolved once; reapply on trait changes.
They might follow up with
- How do I test it? Resolve the colour against explicit trait collections in a unit test, as the check does, then assert a minimum ratio.
- Does Increase Contrast change custom images? Not run; supply high-contrast image variants in the catalog.
Related questions
- 24.2.2 How do you respect Reduce Motion, Increase Contrast, Bold Text, Reduce Transparency, Differentiate Without…
- 24.2.3 What contrast ratio and touch-target size should you meet, and how do you design for colour blindness?
- 24.6.1 What can a unit test assert about accessibility properties, and why does a standard UIButton report no label…
What happens when text contains characters the font does not have… →