Skip to content
ColorTokensKitby Penguin Design Ventures
Contents
Explanation

Why use ColorTokensKit over native colors?

What you gain over SwiftUI's system colors, asset catalogs and hex values, and what it costs.

Updated View as Markdown

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#

swift
// 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 controls
#ff383c
Color.red
#ff8d28
Color.orange
#ffcc00
Color.yellow
#34c759
Color.green
#00c8b3
Color.mint
#00c3d0
Color.teal
#00c0e8
Color.cyan
#0088ff
Color.blue
#6155f5
Color.indigo
#cb30e0
Color.purple
#ff2d55
Color.pink
#ac7f5e
Color.brown

Each 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:

#ff383c
Color.red
#ff9c9e
Color.red.secondary
#ffcdce
Color.red.tertiary
#ffdbdc
Color.red.quaternary
#0088ff
Color.blue
#80c3ff
Color.blue.secondary
#bfe1ff
Color.blue.tertiary
#d1eaff
Color.blue.quaternary

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. .secondary to .quaternary are 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#

Lisbon
Color.red, 3.57:1
Lisbon
Color.green, 2.22:1
Lisbon
Color.blue, 3.52:1
Lisbon
proRed._600, 4.68:1
Lisbon
proGreen._600, 4.68:1
Lisbon
proBlue._600, 4.68:1

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#

swift
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 dark
light mode
Lisbon3 nights from October 12

Each 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#

swift
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)
}
light mode
BeachCityFood

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#

swift
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)
Color.proOrange · light mode
Book nowBook nowSold out
Color.proPink · light mode
Book nowBook nowSold out
Color.proSky · light mode
Book nowBook nowSold out

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#

#ff383c
Color.red
#fd8f8e
proRed._350
  • Quieter colors. Keeping every hue evenly matched costs vividness: proRed._350, our most vivid red, has 70% of the chroma of Color.red.
  • Olive yellows. Yellow reads as olive from _600 down, 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#

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.