Prove your text is readable in code: call contrastRatio(to:) on the background and pass the text. You get the WCAG 2 contrast ratio that accessibility audits check, or an APCA score with method: .apca.
let brand = ProTheme(hex: "#00B386")
let background = brand._100
let text = brand._700
background.contrastRatio(to: text) // 5.53, passes WCAG AA for body text
background.contrastRatio(to: text, method: .apca) // 69.6Try your own pair: stops, tokens in either mode, or any hex color.
Large text, 24 points bold
Body text at 16 points. The quick brown fox jumps over the lazy dog.
WCAG 2 5.53:1
APCA Lc 70.1
let background = Color.proBlue._100.toColor()
let text = Color.proBlue._700.toColor()
background.contrastRatio(to: text) // 5.53
background.contrastRatio(to: text, method: .apca) // 70.1Meeting WCAG AA and AAA#
Most apps aim for AA: 4.5:1 for body text, and 3:1 for large text, icons that carry meaning and the borders of controls.
AAA asks 7:1 for body text. Large text is at least 18 point, or 14 point bold, and text in a disabled control, such as a Sold out button, has no minimum.
Checking a token in each mode#
On a token, contrastRatio(to:) measures one mode: light mode on iOS 17, macOS 14 and later, whatever mode your app is in, and the current appearance before that. To check both modes, compare the token's stops:
brand.backgroundPrimary.contrastRatio(to: brand.foregroundSecondary) // 8.72, light mode only
// The same pair from its stops: _800 on _50 in light mode, _200 on _1000 in dark
brand._50.contrastRatio(to: brand._800) // 8.72
brand._1000.contrastRatio(to: brand._200) // 11.48Understanding semantic tokens lists the stops behind every token.
Testing your pairs#
A unit test keeps a theme change from lowering contrast unnoticed. This one checks secondary text on the page background in both modes, for each of the app's themes:
import ColorTokensKit
import SwiftUI
import Testing
struct ContrastTests {
// The brand, then Lisbon, Kyoto and Reykjavík
@Test(arguments: [ProTheme(hex: "#00B386"), Color.proOrange, Color.proPink, Color.proSky])
func secondaryTextPassesAA(theme: ProTheme) {
// foregroundSecondary on backgroundPrimary: _800 on _50 in light mode, _200 on _1000 in dark mode
#expect(theme._50.contrastRatio(to: theme._800) >= 4.5)
#expect(theme._1000.contrastRatio(to: theme._200) >= 4.5)
}
}All four pass, at 8.72:1 or more. If a pair of yours fails, move the text a stop further from its background.
Choosing between WCAG 2 and APCA#
Your colors have to pass WCAG 2, which audits check and accessibility laws refer to. But Color.js notes that it gives false positives and false negatives, particularly in dark mode. APCA, a candidate for WCAG 3, models how people read on screens, and here the two disagree:
// foregroundTertiary on backgroundPrimary
brand._50.contrastRatio(to: brand._700) // 6.10, light mode
brand._1000.contrastRatio(to: brand._300) // 9.00, dark mode
brand._50.contrastRatio(to: brand._700, method: .apca) // 76.0
brand._1000.contrastRatio(to: brand._300, method: .apca) // -65.0WCAG 2 rates the dark pair as more readable. APCA rates it lower, under the Lc 75 its Bronze level asks for body text. We recommend passing WCAG 2 and using APCA as a second opinion, especially in dark mode.
How it behaves#
For WCAG 2 the order doesn't matter: you get the same ratio, from 1 to 21, either way round. For APCA it does: call the function on the background and pass the text. An Lc is positive for dark text on a light background, negative for light on dark, and 0 for a pair too close to read. The doc comment on .apca calls Lc 60 typical for body text, but APCA asks 75 there, and 60 for other text.
It measures a color beyond sRGB as it is, without clipping it into sRGB first, so a Display P3 stop gets its true contrast.
It ignores opacity, so measure a translucent token such as surfacePrimary over its background, as people see it.
relativeLuminance on RGBColor gives the number WCAG 2 builds its ratio from, from 0 for black to 1 for white: color.toRGB().relativeLuminance.
API#
contrastRatio(to:method:) is on Color, ProTheme, RGBColor, OKLCHColor and LCHColor, each taking a color of its own type.
public enum ContrastMethod {
case wcag2 // WCAG 2.x ratio, 1 to 21
case apca // APCA Lc, signed
}
public extension Color {
func contrastRatio(to other: Color, method: ContrastMethod = .wcag2) -> CGFloat
}
public extension ProTheme {
func contrastRatio(to other: ProTheme, method: ContrastMethod = .wcag2) -> CGFloat
}
// RGBColor, OKLCHColor and LCHColor have the same function, taking their own type.
public extension RGBColor {
var relativeLuminance: CGFloat { get } // WCAG 2.x, 0 to 1
}| Parameter | Type | Default | What other values do |
|---|---|---|---|
other | the same type | none | The text color. For WCAG 2 either color can come first. |
method | ContrastMethod | .wcag2 | .apca returns an APCA Lc, signed by polarity, with the receiver as the background. |
It returns a CGFloat: a ratio from 1 to 21 for .wcag2, or an Lc for .apca.
Sources#
- W3C, WCAG 2.2: success criteria 1.4.3, 1.4.6 and 1.4.11, and the definitions of relative luminance and large text.
- Color.js, Contrast, on the known problems with WCAG 2 contrast and the status of APCA.
- APCA, Bronze simple mode, for the Lc levels.
- ColorTokensKit source, ContrastRatio.swift.
See also
- How accessible is it? What the palette guarantees for contrast in every hue and mode, and what you still need to check.
- High contrast modes Respect Increase Contrast with one rule for every family: text and borders two stops stronger.
- Color theory Pair stops that always read, and pick secondary hues that match your brand, in both modes.
- .toRGB() Read a color's red, green and blue channels, even past sRGB, to hand to other graphics code.