Apple's high contrast mode is the Increase Contrast setting in Accessibility. Tokens don't follow it on their own, so your view reads it and asks for stronger colors: text and borders two stops further from the background, in every family. Apple's Human Interface Guidelines ask custom colors to offer an increased contrast option.
Strengthen colors when the setting is on#
A trip card that follows the setting, shown off and then on:
extension Color {
func strengthened(by stops: Int, when contrast: ColorSchemeContrast) -> Color {
contrast == .increased ? strengthen(by: stops) : self
}
}
struct TripCard: View {
@Environment(\.colorSchemeContrast) private var contrast
let theme: ProTheme
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Lisbon")
.font(.headline)
.foregroundStyle(theme.foregroundPrimary)
Text("3 nights from October 12") // _800 becomes _900 in light mode, _200 becomes _100 in dark
.font(.subheadline)
.foregroundStyle(theme.foregroundSecondary.strengthened(by: 2, when: contrast))
}
.padding()
.border(theme.outlinePrimary.strengthened(by: 2, when: contrast)) // _600 and _350 become _700 and _250
.padding()
.background(theme.backgroundPrimary)
}
}SwiftUI reports the setting as colorSchemeContrast. strengthen(by:) moves a color away from the background, darker in light mode and lighter in dark, and a token lands on an exact stop.
Contrast with the setting on#
Tertiary text lands where secondary text was. The border already passes the 3:1 WCAG asks of borders people need to see with the setting off, and with it on it measures 6.10:1 in light mode and 10.20:1 in dark. Leave foregroundPrimary as it is: it's already at the end of the ramp.
In dark mode, strengthened tertiary text also passes the Lc 75 that APCA asks for body text.
UIKit and AppKit#
In UIKit, read the accessibilityContrast trait in a dynamic UIColor built from the token's stops. UIColor takes a SwiftUI Color, not a stop, so the stop goes through toColor():
import UIKit
// foregroundSecondary, two stops stronger when Increase Contrast is on
let datesColor = UIColor { traits in
let increased = traits.accessibilityContrast == .high
let stop = traits.userInterfaceStyle == .dark
? (increased ? theme._100 : theme._200)
: (increased ? theme._900 : theme._800)
return UIColor(stop.toColor())
}Set layer colors again when the traits change, as the UIKit and AppKit pages show.
AppKit reports it through the appearance:
import AppKit
// foregroundSecondary, two stops stronger when Increase Contrast is on
let datesColor = NSColor(name: nil) { appearance in
let stop = switch appearance.bestMatch(from: [.aqua, .darkAqua, .accessibilityHighContrastAqua, .accessibilityHighContrastDarkAqua]) {
case .darkAqua: theme._200
case .accessibilityHighContrastAqua: theme._900
case .accessibilityHighContrastDarkAqua: theme._100
default: theme._800
}
return NSColor(stop.toColor())
}This follows Apple's NSAppearance documentation. We haven't tested it with the setting on.
Troubleshooting#
Setting the contrast in a preview doesn't compile#
colorSchemeContrast is read-only, because SwiftUI takes it from the system. To preview the stronger colors, pass .increased to the helper, or use Xcode's Environment Overrides while your app runs.
Colors don't change when the setting is on#
A color stored once, in a static let for example, can't see the environment. Read colorSchemeContrast in the view, as TripCard does.
See also
- How accessible is it? What the palette guarantees for contrast in every hue and mode, and what you still need to check.
- .contrastRatio(to:method:) Check WCAG 2 and APCA contrast in Swift, and keep your color pairs passing with a unit test.
- Managing dark mode Get dark mode without extra code: tokens switch stops for you, and your own colors can too.
- Building for interaction states Give buttons and rows hover, pressed, selected and disabled colors that stay readable in both modes.