Swap each hard-coded or asset-catalog color for the semantic token that does its job, and it gets a dark-mode color and a contrast you can count on. You can move one screen at a time: search for Color(", Color(red:, Color(white:, UIColor(named: and NSColor(named:, then work through what you find, kind by kind, in the order below.
Use the gray tokens on Color for everyday backgrounds, text and borders, and your brand's own tokens where a color carries the brand.
Turn your brand color into a family#
Start with your brand, so the rest of the swap has a family to use. Make one from your brand's hex value:
extension Color {
static let brand = ProTheme(hex: "#00B386")
}
Text("Your flight to Kyoto boards at 14:30.")
.foregroundStyle(Color.brand.foregroundPrimary) // _1000 in light mode, _50 in dark
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.brand.backgroundPrimary) // _50 in light mode, _1000 in darkThe family has all 20 stops and every token, with the same contrast as the built-in themes. Your hex lands between its _400 and _450, and Color.brand.toColor() is still exactly #00B386 for places like a logo. Keep the exact hex out of text: on white it measures 2.69:1, while the family's text tokens pass AA in both modes. ProTheme(hex:) covers every way to read a hex.
Replacing backgrounds#
Backgrounds go by depth: backgroundPrimary for the screen, backgroundSecondary for cards and grouped sections, and backgroundTertiary for what sits on a card. A layer that lets content show through, like a sheet over a photo, takes a surface token.
VStack(alignment: .leading, spacing: 8) {
Text("Upcoming trips")
.font(.headline)
Text("Lisbon, October 12")
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.backgroundTertiary, in: .rect(cornerRadius: 10)) // was white: 0.9
}
.foregroundStyle(Color.foregroundPrimary)
.padding()
.background(Color.backgroundSecondary, in: .rect(cornerRadius: 16)) // was Color(white: 0.96)Understanding semantic tokens shows which token paints which part of a screen.
Replacing text colors#
Text goes by importance: foregroundPrimary for titles and body text, where you had .primary or .black; foregroundSecondary for subtitles, where you had .secondary or a mid gray; and foregroundTertiary for hints and captions. Every text token reaches 4.5:1 on backgroundPrimary and backgroundSecondary, in both modes and in every family.
VStack(alignment: .leading, spacing: 4) {
Text("Lisbon")
.font(.headline)
.foregroundStyle(Color.foregroundPrimary) // was .black
Text("3 nights from October 12")
.font(.subheadline)
.foregroundStyle(Color.foregroundSecondary) // was Color(white: 0.4)
}An extra shade, such as a SubtitleMuted, comes from one token with soften(by:) or strengthen(by:) instead of a new color. Text on a filled button takes the inverted tokens, shown under buttons below.
Replacing outlines and dividers#
A control's edge, like a text field or a checkbox, takes outlinePrimary, which reaches 3:1 on every background token in both modes, as WCAG asks for edges people need to see. Dividers and other decorative lines take outlineSecondary or outlineTertiary, which stay quiet.
VStack(alignment: .leading, spacing: 12) {
TextField("City or airport", text: .constant(""))
.padding(12)
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.outlinePrimary)) // was gray
Divider()
.overlay(Color.outlineTertiary) // was .gray.opacity(0.2)
Text("Recent searches")
.foregroundStyle(Color.foregroundSecondary)
}Borders people need to see has the numbers for each background.
Replacing button and state colors#
A filled button takes a theme's inverted tokens: invertedBackgroundTertiary behind invertedForegroundPrimary. Instead of more color sets for pressed and disabled, derive them from the fill: darken(by:) lands one stop darker, and desaturate(by: 1) turns it gray at the same lightness, so the label keeps its contrast.
let fill = Color.proIndigo.invertedBackgroundTertiary // was Color("ButtonBlue")
HStack {
Text("Book").padding(12).background(fill, in: Capsule())
Text("Pressed").padding(12).background(fill.darken(by: 1), in: Capsule())
Text("Sold out").padding(12).background(fill.desaturate(by: 1), in: Capsule())
}
.foregroundStyle(Color.proIndigo.invertedForegroundPrimary)Building for interaction states puts hover, pressed, selected and disabled in one button style.
Replacing gradients#
A hand-built LinearGradient between two asset colors becomes two tokens and proGradient(). It blends through OKLCH, so the middle stays vivid instead of going gray, and token ends switch with dark mode.
let sunset = Color.proOrange
// was LinearGradient(colors: [Color("Peach"), Color("Apricot")], startPoint: .top, …)
let colors: [Color] = [sunset.backgroundSecondary, sunset.backgroundTertiary]
RoundedRectangle(cornerRadius: 16)
.fill(colors.proGradient())
.frame(height: 80)Gradient theory explains the blends and recipes.
Replacing chart colors#
A chart's hand-picked series colors become one family per series. Spread them around the wheel with a harmony such as .triad, so neighbors stay apart, and give each series a second cue, like a label or a pattern, for readers who can't tell hues apart. Using tokens in charts shows which stops to use on each background.
Fixed colors and the asset catalog#
Some colors shouldn't switch with dark mode. A brand fill that looks the same in both, like a green primary button, takes a fixed stop: white text on _600 measures 4.68:1, and black text on _400 8.46:1, in every hue. A logo takes your exact hex, Color.brand.toColor().
Everything else can leave the asset catalog. Check your storyboards too, which name color sets as text; the UIKit and AppKit pages show tokens in those views. Then delete the color sets from Assets.xcassets, but keep AccentColor if your target's Global Accent Color Name build setting points to it.
Check your work#
- Dark mode. Switch the appearance on each screen you moved. A color that doesn't change is a stop, such as
Color.proGray._700.toColor(), where a token belongs, so use the token for the job. Managing dark mode explains the difference. - Contrast. Measure the pairs you built yourself with
contrastRatio(to:method:), in a unit test if you like. - High contrast. Tokens don't follow the Increase Contrast setting on their own. High contrast modes shows the one rule to add.
- A gray brand color. If it came out mustard, it was built from its hue with
.primary(forHue:). A gray has no real hue, and#333333reads as 89.88°, so build it withProTheme(hex: "#333333"), which sees the gray, gives you the gray family and keeps your exact hex.
Next steps
- 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.
- Setting up themes Recolor a view, a screen or your whole app from one value, with the same contrast in every hue.
- ProTheme Make a theme from a hex, a hue or OKLCH numbers, and every stop and token keeps its contrast.