Skip to content
ColorTokensKitby Penguin Design Ventures
Contents
Explanation

What are tokens?

Colors picked one at a time never line up. Tokens name each job and pick its color in both modes.

Updated View as Markdown

Tokens are how design teams keep an app's colors consistent across every screen, theme and mode. To see why they matter, start with how most apps handle colors today.

The problem with how most apps are built#

Most apps keep their colors in one file: the brand color as a constant, a few asset catalog colors with a light and a dark value, and SwiftUI's own colors for the rest.

swift
extension Color {
    static let brand = Color(hex: "#F27333")          // from the brand guide
    static let brandPressed = Color(hex: "#D9602A")   // a darker orange, picked by eye
    static let card = Color("Card")                   // asset catalog: #F4F2EF light, #1C1C1E dark
    static let textMuted = Color("TextMuted")         // asset catalog: #6B6B70 light, #A1A1A6 dark
}

struct TripCard: View {
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack {
                Text("Lisbon")
                    .font(.headline)
                    .foregroundStyle(.primary)        // SwiftUI's own: black, or white in dark mode
                Spacer()
                Text("€620")
                    .foregroundStyle(Color.brand)
            }
            Text("3 nights from October 12")
                .font(.subheadline)
                .foregroundStyle(Color.textMuted)
            Text("Book now")
                .padding(8)
                .foregroundStyle(.white)
                .background(Color.brand)
        }
        .padding()
        .background(Color.card)
    }
}
light mode
Lisbon€620
3 nights from October 12Book now

This works. The card follows dark mode through the asset catalog, the brand color lives in one place, and SwiftUI fills in the rest. The problems show up as the app grows:

  • Nothing checks the colors. White on the brand orange measures 2.89:1, and the orange price on the light card 2.58:1, both well under the 4.5:1 that WCAG AA asks for text. Every pair is a guess until someone measures it.
  • Dark mode by hand. Each asset needs a dark value someone picks and checks, and the brand orange and its pressed shade stay the same in both modes.
  • Shades picked by eye. brandPressed is a guess, and a disabled button, a hover state or a tag tint is another hex value each time.
  • Hard to scale. A second theme or a new feature color means another set of assets and another round of checks.

Even carefully picked colors don't line up. A color picker calls these four equally light, yet as text on white one vanishes and another is nearly black:

Lisbon
hsl(60 100% 50%), 1.07:1
Lisbon
hsl(120 100% 50%), 1.37:1
Lisbon
hsl(0 100% 50%), 4.00:1
Lisbon
hsl(240 100% 50%), 8.59:1

How tokens fix it#

HueproOrange sits at 50° of 36 hues
Ramp20 stops, from _50 to _1000
_800_200
StopsEach one a fixed color
lightdark
JobforegroundSecondary: a stop for each mode
Lisbon3 nights
Lisbon3 nights
Your appSecondary text, in both modes

Tokens fix it in two steps: every hue gets the same lightness at each stop, and each job, like secondary text, gets a name that picks the right stop in each mode. The rest of this page follows that path, from a hue to your app.

One hue becomes 20 stops#

50
100
150
200
250
300
350
400
450
500
550
600
650
700
750
800
850
900
950
1000
Color.proOrange, OKLCH hue 50°

We turn each family's hue into 20 stops, from near-white _50 to near-black _1000. A stop is one fixed color: Color.proOrange._600 is the same orange in both modes. All 36 hues share one lightness at each stop, so a stop you check once is checked in every hue.

A token names a job#

50
100
150
200
250
300
350
400
450
500
550
600
650
700
750
800
850
900
950
1000
↑ dark mode
↑ light mode
Color.proOrange, OKLCH hue 50°

Your views don't need "orange _800". They need secondary text, a card background, a border. A token names that job and picks a stop for each mode: foregroundSecondary picks _800 in light mode and _200 in dark, the same distance from each end of the ramp:

swift
Text("3 nights from October 12")
    .font(.subheadline)
    .foregroundStyle(Color.proOrange.foregroundSecondary)   // _800 in light mode, _200 in dark
light mode
3 nights from October 12

In ColorTokens.swift, the file of tokens you copy into your app, the token is those two stops and nothing more. Defining a token is where toColor() belongs: it turns a stop into the SwiftUI Color that Color(light:dark:) takes.

swift
public extension ProTheme {
    var foregroundSecondary: Color {
        Color(light: _800.toColor(), dark: _200.toColor())
    }
}

We chose each token's stops so every text token passes WCAG AA on the page background, backgroundPrimary, in both modes and every hue.

A token for every part of a view#

We give you 20 tokens that cover a whole screen: foreground for text and icons, background for screens and cards, surface for see-through layers, outline for borders, and inverted versions for areas in the opposite appearance. Here's one of each:

swift
let theme = Color.proOrange

VStack(alignment: .leading, spacing: 8) {
    Text("Lisbon")
        .font(.headline)
        .foregroundStyle(theme.foregroundPrimary)            // foreground: text and icons
    Text("Flight included")
        .font(.subheadline)
        .padding(4)
        .foregroundStyle(theme.foregroundSecondary)
        .background(theme.surfacePrimary)                    // surface: see-through layers
        .border(theme.outlineSecondary)                      // outline: borders
    Text("Book now")
        .padding(8)
        .foregroundStyle(theme.invertedForegroundPrimary)    // inverted: the opposite appearance
        .background(theme.invertedBackgroundPrimary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.backgroundPrimary)                         // background: screens and cards
light mode
LisbonFlight includedBook now

Most groups have a primary, secondary and tertiary token. Understanding semantic tokens lists them all.

Theme stops, theme tokens and app tokens#

Every ProTheme, like Color.proOrange, has 20 stops and all 20 tokens. There's also one more set of tokens for the app as a whole:

ExampleIn dark mode
Theme stopColor.proOrange._800Stays _800
Theme tokenColor.proOrange.foregroundSecondarySwitches to orange _200
App tokenColor.foregroundSecondarySwitches to gray _200

Theme tokens let a view change theme in one line, as setting up themes shows. App tokens are the same 20 names on Color itself, built on gray, from ColorTokens.swift. Use them for the parts of your app that don't belong to a theme, such as settings:

swift
HStack {
    Text("Price alerts")
        .foregroundStyle(Color.foregroundPrimary)
    Spacer()
    Text("On")
        .font(.subheadline)
        .foregroundStyle(Color.foregroundSecondary)
}
.padding()
.background(Color.backgroundPrimary)   // white in light mode, black in dark
light mode
Price alertsOn

A theme's page color keeps a little of its hue: Color.proOrange.backgroundPrimary is _50, a faint orange, in light mode, where the app token Color.backgroundPrimary is white.

Sources#

See also