Color text, buttons, cards and lists by the job each color does, and each one follows light and dark mode on its own.
Color text by importance#
VStack(alignment: .leading, spacing: 4) {
Text("Lisbon")
.font(.headline)
.foregroundStyle(Color.foregroundPrimary) // _1000 in light mode, _50 in dark
Text("3 nights from October 12")
.font(.subheadline)
.foregroundStyle(Color.foregroundSecondary) // _800 in light mode, _200 in dark
Text("Updated 2 min ago")
.font(.footnote)
.foregroundStyle(Color.foregroundTertiary) // _700 in light mode, _300 in dark
}This assumes you've added the package and copied ColorTokens.swift. If you haven't, set it up first.
These are the gray tokens on Color. Even tertiary, the faintest, measures 9.55:1 or more against the page in both modes.
Fill or outline a button#
let brand = ProTheme(hex: "#00B386")
HStack {
Button { } label: {
Text("Book now")
.padding(.vertical, 10)
.padding(.horizontal, 16)
.foregroundStyle(brand.invertedForegroundPrimary) // _50 in light mode, _1000 in dark
.background(brand.invertedBackgroundPrimary) // _900 in light mode, _50 in dark
}
Button { } label: {
Text("Details")
.padding(.vertical, 10)
.padding(.horizontal, 16)
.foregroundStyle(brand.foregroundPrimary)
.border(brand.outlinePrimary) // _600 in light mode, _350 in dark
}
}
.buttonStyle(.plain)For color, take the tokens from a family, here one made from the brand's hex. A filled button takes the inverted tokens. An outlined one takes outlinePrimary, which clears 3:1 against the page, so people can find the control's edge.
Lay out a card#
let theme = Color.proOrange // Lisbon's color
VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("Lisbon")
.font(.headline)
.foregroundStyle(theme.foregroundPrimary)
Text("3 nights from October 12")
.font(.subheadline)
.foregroundStyle(theme.foregroundSecondary)
}
HStack(spacing: 6) {
ForEach(["Beach", "City"], id: \.self) { tag in
Text(tag)
.font(.caption)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.foregroundStyle(theme.foregroundPrimary)
.background(theme.backgroundTertiary) // _200 in light mode, _700 in dark
}
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.backgroundPrimary) // _50 in light mode, _1000 in darkThe card takes the page color, backgroundPrimary, and its tags sit a layer up on backgroundTertiary. Text on the tags takes foregroundPrimary, because foregroundSecondary drops to 4.48:1 there in dark mode.
Build a list row#
struct SettingRow: View {
let title: String
let value: String
var body: some View {
VStack(spacing: 0) {
HStack {
Text(title)
.foregroundStyle(Color.foregroundPrimary)
Spacer()
Text(value)
.foregroundStyle(Color.foregroundSecondary)
Image(systemName: "chevron.right")
.foregroundStyle(Color.foregroundTertiary)
}
.padding()
Rectangle()
.fill(Color.outlineSecondary) // _200 in light mode, _800 in dark
.frame(height: 1)
}
}
}
VStack(spacing: 0) {
SettingRow(title: "Price alerts", value: "On")
SettingRow(title: "Home airport", value: "LIS")
}The label is primary, the value secondary and the chevron tertiary. The divider takes an outline token, which is faint on purpose.
Build a movie card#
struct MovieCard: View {
let title: String
let details: String
let match: String
let poster: String
let theme: ProTheme
var body: some View {
HStack(alignment: .top, spacing: 14) {
AsyncImage(url: URL(string: "https://picsum.photos/seed/\(poster)/300/450")) { image in
image.resizable().scaledToFill()
} placeholder: {
theme.backgroundTertiary
}
.frame(width: 96, height: 144)
.clipShape(.rect(cornerRadius: 16))
VStack(alignment: .leading, spacing: 10) {
Text(match)
.font(.caption.bold())
.padding(.vertical, 4)
.padding(.horizontal, 10)
.foregroundStyle(theme.foregroundPrimary)
.background(theme.backgroundTertiary, in: .capsule)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.title3.bold())
.foregroundStyle(theme.foregroundPrimary)
Text(details)
.font(.subheadline)
.foregroundStyle(theme.foregroundSecondary)
}
Label("Watch trailer", systemImage: "play.fill")
.font(.subheadline.bold())
.padding(.vertical, 8)
.padding(.horizontal, 14)
.foregroundStyle(theme.invertedForegroundPrimary)
.background(theme.invertedBackgroundTertiary, in: .capsule)
}
Spacer(minLength: 0)
}
.padding(12)
.background(
[theme.surfaceTertiary, theme.surfacePrimary].proGradient(),
in: .rect(cornerRadius: 28)
)
.overlay(RoundedRectangle(cornerRadius: 28).strokeBorder(theme.outlineTertiary))
}
}
MovieCard(
title: "Sintra Summer", details: "2024 · 1h 48m · Romance",
match: "🍿 92% match", poster: "sintra", theme: Color.proRose
)A movie app could show a card like this for each recommendation, and every color in it comes from one theme. The match pill sits on backgroundTertiary, the trailer button uses the inverted tokens, and the wash is a gradient between two translucent surface tokens. The details keep 7.4:1 or more even where the wash is strongest, in every hue. The poster's corners are the card's radius minus its padding, so the two curves stay parallel.
Theme it in one line#
Pass each movie a theme that suits its mood, and nothing else changes:
VStack(spacing: 12) {
MovieCard(
title: "Night Train to Porto", details: "2023 · 2h 4m · Thriller",
match: "🍿 88% match", poster: "porto", theme: Color.proIndigo
)
MovieCard(
title: "The Lisbon Bakery", details: "2025 · 1h 31m · Comedy",
match: "🍿 95% match", poster: "bakery", theme: Color.proAmber
)
}Every family has the same lightness at each stop, so the text keeps its contrast in every theme. Setting up themes takes this to a whole screen.
Next steps
- Replacing your app colors Move backgrounds, text, outlines, states, gradients and charts to tokens, one screen at a time.
- Building for interaction states Give buttons and rows hover, pressed, selected and disabled colors that stay readable in both modes.
- Managing dark mode Get dark mode without extra code: tokens switch stops for you, and your own colors can too.
- Understanding semantic tokens Learn the 20 tokens by the job each one does, and get dark mode and passing contrast with them.
- Components and examples Copy the common mobile components, from buttons to paywalls, each built from tokens for both modes.