ColorTokensKit gives your SwiftUI app the color system a design team would build for it. To see what that adds, start with what SwiftUI gives you on its own.
What SwiftUI gives you#
// 12 system colors, each with a light and a dark version
let accents: [Color] = [.red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, .indigo, .purple, .pink, .brown]
let brand = Color("Brand") // your own colors, from an asset catalog
VStack(alignment: .leading) {
Text("Lisbon")
.foregroundStyle(.primary) // black in light mode, white in dark
Text("3 nights from October 12")
.font(.subheadline)
.foregroundStyle(.secondary) // a dimmer primary; .tertiary and .quaternary go further
Text("Beach")
.padding(4)
.background(Color.orange.quaternary) // any color has .secondary to .quinary levels
RoundedRectangle(cornerRadius: 8)
.fill(Color.orange.gradient) // and a gentle gradient
.frame(height: 24)
}
.padding()
.background(.background) // white in light mode, black in dark
.tint(.orange) // the accent for buttons and controlsEach color also has four lighter levels. On iPhone, .secondary is the color at 50% opacity, .tertiary at 25%, and .quaternary at about 18%; in our tests .quinary drew the same as .quaternary. Here they are on white:
That's a good start: text that follows dark mode, a background that does too, a dozen accents, and lighter levels of each.
Where native colors fall short#
- Uneven. Each system color is tuned on its own, so as text on white they run from 1.51:1 to 5.09:1. Only indigo reaches the 4.5:1 that WCAG AA asks for body text.
- Levels, not shades.
.secondaryto.quaternaryare the same color at lower opacity, so they blend with whatever sits behind them: pink on white, a dim red on black. None of them is a deeper red for text on white, and each level measures differently on every background. - No palette to grow into. Your brand color, or a 13th hue, means building its whole range of shades by hand.
- Verbose. Every custom color is a hex value or an asset catalog entry, with a light and a dark variant, for each shade you use.
- Dark mode by hand. System colors switch on their own, but every color you add needs a dark value you pick and check.
- Hard to scale. Each new theme, pressed state or disabled state is another set of colors to pick and check for contrast.
Benefit 1: The same contrast in every hue#
The top row is SwiftUI's system colors as text on white, and the bottom row is ours at _600. Our 36 hues share one lightness at each stop, so a stop measures the same in every one: _600 passes AA as text on white in all of them, including a hue you make from your brand color. How did we choose and build these colors? explains how.
Benefit 2: Dark mode in every token#
VStack(alignment: .leading, spacing: 4) {
Text("Lisbon")
.font(.headline)
.foregroundStyle(Color.proOrange.foregroundPrimary) // _1000 in light mode, _50 in dark
Text("3 nights from October 12")
.font(.subheadline)
.foregroundStyle(Color.proOrange.foregroundSecondary) // _800 in light mode, _200 in dark
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.proOrange.backgroundPrimary) // _50 in light mode, _1000 in darkEach token picks one stop for light mode and one for dark, chosen so text passes on its background in both. You never pick a dark value or check it by hand. What are tokens? shows how a token picks its stops.
Benefit 3: A new theme in one line#
struct Tag: View {
let title: String
let theme: ProTheme
var body: some View {
Text(title)
.font(.subheadline)
.padding(.vertical, 4)
.padding(.horizontal, 10)
.foregroundStyle(theme.foregroundPrimary)
.background(theme.backgroundTertiary)
}
}
HStack {
Tag(title: "Beach", theme: Color.proSky)
Tag(title: "City", theme: Color.proPink)
Tag(title: "Food", theme: Color.proOrange)
}Every family has the same tokens at the same contrast, so a view that takes a family can wear any of them. The label measures 11.48:1 in light mode and 6.10:1 in dark, in all three themes and every other hue. Setting up themes shows how far this goes.
Benefit 4: Pressed and disabled from the color you have#
let fill = theme.invertedBackgroundTertiary // theme is any family: _650 in light mode, _250 in dark
HStack {
Text("Book now").padding(12).background(fill)
Text("Book now").padding(12).background(fill.darken(by: 2)) // pressed: _750 in light mode, _350 in dark
Text("Sold out").padding(12).background(fill.desaturate(by: 1)) // disabled: gray at the same lightness
}
.foregroundStyle(theme.invertedForegroundPrimary)With native colors, each state is one more color to pick and check. Here, darken(by: 2) moves the fill two stops, so the pressed color is still a palette color, and desaturate(by: 1) keeps the lightness, so "Sold out" keeps the button's contrast: 5.11:1 in light mode and 10.20:1 in dark. darken(by:) and desaturate(by:) explain both.
What it costs#
- Quieter colors. Keeping every hue evenly matched costs vividness:
proRed._350, our most vivid red, has 70% of the chroma ofColor.red. - Olive yellows. Yellow reads as olive from
_600down, where it's as dark as blue. - Your brand sits between stops. Your brand's exact hex usually isn't one of its family's stops, though
toColor()still gives you the exact hex. - Increase Contrast is up to you. Tokens don't follow it on their own; high contrast modes shows how.
- Display P3 only. Many stops are beyond sRGB, with no fallback, so an sRGB-only screen, such as many external monitors, clips them.
- One more dependency. The package has none of its own.
What does equal lightness cost? has the numbers. You don't have to switch all at once: replacing your app colors moves one screen at a time.
Sources#
- Apple, Human Interface Guidelines: Color, for the system colors, in their iOS 26 light-mode values.
- W3C, WCAG 2.2: contrast minimum.
- The library's ColorTokens.swift, which defines the tokens.
See also
- What does the library offer? 36 color themes, ready-to-use tokens with dark mode, a theme for your brand, and state functions.
- Using your first tokens Color text, buttons, cards and lists with tokens, then theme a movie card in one line.
- How accessible is it? What the palette guarantees for contrast in every hue and mode, and what you still need to check.
- Why OKLCH? Every hue looks evenly matched at each stop, so you can swap families without rechecking contrast.