Color theory here comes down to two questions: which colors read clearly on each other, and which hues sit well together. A counting rule answers the first and harmonies the second, in every hue and in both modes. The semantic tokens already pair up for you. For stops you pick yourself, count them: text 12 stops from its background passes WCAG AA in every hue.
let theme = Color.proOrange
VStack(alignment: .leading, spacing: 4) {
Text("Lisbon")
.font(.headline)
.foregroundStyle(theme.foregroundPrimary) // _1000 in light mode, _50 in dark
Text("3 nights from October 12")
.font(.subheadline)
.foregroundStyle(theme.foregroundSecondary) // _800 and _200
Text("Beach")
.font(.subheadline)
.padding(.horizontal, 6)
.foregroundStyle(theme.foregroundPrimary)
.background(theme.backgroundTertiary) // _200 and _700
}
.padding()
.background(theme.backgroundPrimary) // _50 and _1000Every foreground token passes AA on backgroundPrimary and backgroundSecondary in both modes. On backgroundTertiary, only foregroundPrimary passes in both, so the tag uses it. For hues that go with your brand, see secondary and tertiary colors.
The distance rule#
When you pick stops yourself, for a badge, a chart or a custom control, count the stops between the text and its background:
- 9 stops apart reaches 3:1, for icons, control borders and large text.
- 12 stops apart passes AA for body text, which needs 4.5:1.
- 14 stops apart passes AAA, which needs 7:1.
These are the lowest ratios anywhere in the palette, and they hold between families too: blue text on a gold background follows the same rule. On the translucent surface tokens, measure against the color the reader sees.
Drag the text and background stops to measure the pair in every family:
The 36 hues: 5.53 to 5.53:1Gray has its own lightness ladder, so it can differ.
The 36 hues land within 0.01 of each other for any pair, because every hue shares each stop's lightness. Gray has its own ladder, and 9 or more stops apart it measures higher.
What each range of stops is for#
The rule gives each part of a family a job. In light mode:
Dark mode swaps the ends. Yellow turns olive from _600 down, so use proGold for dark warm text. Neighboring hues such as pink, ruby and red look alike at the light stops, so pick hues further apart to tell categories apart. What does equal lightness cost? explains both.
Borders people need to see#
HStack {
ForEach(["Beach", "City", "Food"], id: \.self) { tag in
Button(tag) {}
.buttonStyle(.plain)
.padding(.vertical, 6)
.padding(.horizontal, 12)
.foregroundStyle(brand.foregroundPrimary)
.border(brand.outlinePrimary) // _600 in light mode, _350 in dark
}
}
.padding()
.background(brand.backgroundPrimary) // _50 and _1000The edge of a control needs 3:1 under WCAG's rule for non-text contrast. outlinePrimary reaches it on every background token in both modes, in every hue, so the tags use it. outlineSecondary and outlineTertiary are faint on purpose, for dividers and card edges, which WCAG sets no minimum for. For a stop of your own, pick one at least 9 from the background.
Text on a colored fill#
Text("Book now")
.font(.headline)
.padding(.vertical, 10)
.padding(.horizontal, 16)
.foregroundStyle(brand.invertedForegroundPrimary) // _50 in light mode, _1000 in dark
.background(brand.invertedBackgroundTertiary) // _650 and _250: 5.11:1 and 10.20:1The inverted tokens pair a fill with text that reads on it, in both modes. For a fill that stays the same in both modes, white text needs _600 or darker and black text _550 or lighter, so every stop has a text color that passes AA, in every hue. Gray switches a stop earlier: white from proGray._550, black up to proGray._500.
Secondary and tertiary colors#
These sit beside your brand color, on tags, badges and categories. Call a harmony on your brand's family, and you get whole families at other hues, each with every token:
let secondary = brand.triad[1] // your brand's family, turned 120° around the hue wheel
let tertiary = brand.triad[2] // and turned 240°
HStack {
Text("Beach")
.padding(8)
.foregroundStyle(brand.foregroundPrimary) // _1000 in light mode, _50 in dark
.background(brand.backgroundTertiary) // _200 in light mode, _700 in dark
Text("City")
.padding(8)
.foregroundStyle(secondary.foregroundPrimary)
.background(secondary.backgroundTertiary)
Text("Food")
.padding(8)
.foregroundStyle(tertiary.foregroundPrimary)
.background(tertiary.backgroundTertiary)
}A harmony changes only the hue, so every tag has the same contrast: 11.48:1 in light mode and 6.10:1 in dark. For lighter and darker steps of one hue, use a lightness set instead.
Other hues that go with yours#
Each harmony turns your color around the hue wheel by set angles and keeps its lightness. Try your color and each harmony below, then set "See as" to "Lightness only": every color in the harmony turns the same gray.
Every OKLCH hue at this color's lightness
let color = Color.proBlue._400.toColor()
color.triad| Harmony | Hues, from yours | Good for |
|---|---|---|
complement | 180° | One accent that stands apart |
triad | 0°, 120°, 240° | A primary, secondary and tertiary color |
splitComplement() | 0°, 150°, 210° | Two accents either side of the complement |
analogous() | −30°, 0°, 30° | Close neighbors for banners and illustrations |
square | 0°, 90°, 180°, 270° | Four categories |
tetrad() | 0°, 60°, 180°, 240° | Four colors in two opposite pairs |
An accent that stands apart#
The complement is the hue opposite yours, so it stands out by hue alone. Save it for the one thing that has to catch the eye, such as a price:
let accent = brand.complement // your brand's family, turned 180°
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Lisbon")
.font(.headline)
.foregroundStyle(brand.foregroundPrimary)
Text("3 nights from October 12")
.font(.subheadline)
.foregroundStyle(brand.foregroundSecondary)
}
Text("€480")
.padding(8)
.foregroundStyle(accent.foregroundPrimary) // _1000 in light mode, _50 in dark
.background(accent.backgroundTertiary) // _200 in light mode, _700 in dark
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(brand.backgroundPrimary) // _50 in light mode, _1000 in darkEvery token in the new hues#
Name your secondary and tertiary families once, next to your brand, and every token works on them in both modes:
extension Color {
static let brand = ProTheme(hex: "#00B386")
static let brandSecondary = brand.triad[1]
static let brandTertiary = brand.triad[2]
}Lighter and darker steps of one hue#
Show ordered data in one color, such as cheap to pricey days on a fare calendar, with a lightness set. monochromatic() gives five stops of a color's hue, from lightest to darkest. It uses only the hue, so a token gives the same five in both modes:
let levels = brand.foregroundPrimary.monochromatic() // _50, _300, _550, _750, _1000
HStack(spacing: 4) {
ForEach(levels.indices, id: \.self) { index in
Rectangle()
.fill(levels[index])
.frame(width: 24, height: 24)
.border(brand.outlineSecondary)
}
}The steps differ in lightness, so the scale still reads in grayscale, which a harmony's colors don't. The lightest step nearly vanishes on a white page, at 1.09:1, and the darkest on a black one, at 1.23:1, so each cell gets a border.
tints(count:) gives lighter versions of a color, one stop apart and nearest first, and shades(count:) darker ones. Pick a color and change the counts below:
monochromatic
tints
shades
monochromatic
tints
shades
let color = Color.proGreen._400.toColor()
color.monochromatic()
color.tints()
color.shades()How harmonies behave#
A harmony turns each color with rotateHue(by:), so a palette color lands on the same stop at its new hue. The built-in hues sit every 10°, and every default harmony turns by a multiple of 10°. So a built-in family's harmony is made of built-in families: Color.proBlue.triad is blue, ruby and lime. Gray has no hue, so a gray's harmony is all gray.
Because only hue separates them, a harmony's colors match in grayscale, and some look alike to people with a color vision deficiency. When color carries meaning, add a label or an icon, as using tokens in charts shows.
monochromatic(count:) spreads its colors evenly over the whole ramp, so your color may not be one of them. On a token, tints are lighter in both modes, like lighten(): quieter on a white page, louder on a black one. For quieter in both, use soften(by:).
To pick a harmony at runtime, such as from a picker, pass it to harmony(_:). Each harmony's page has its signature and options.
See also
- Setting up themes Recolor a view, a screen or your whole app from one value, with the same contrast in every hue.
- 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.
- Building for interaction states Give buttons and rows hover, pressed, selected and disabled colors that stay readable in both modes.
- Why OKLCH? Every hue looks evenly matched at each stop, so you can swap families without rechecking contrast.