Skip to content
ColorTokensKitby Penguin Design Ventures
Contents
How-to

Managing dark mode

Get dark mode without extra code: tokens switch stops for you, and your own colors can too.

Updated View as Markdown

Managing dark mode mostly means letting the tokens do it: you get dark mode without writing any dark-mode code. Each semantic token holds two stops, one for light mode and one for dark, and SwiftUI draws the one for the current mode:

swift
let theme = Color.proOrange

VStack(alignment: .leading, spacing: 4) {
    Text("Lisbon")
        .font(.headline)
        .foregroundStyle(theme.foregroundPrimary)     // _1000 in light mode, _50 in dark
    Text("3 nights from October 12")
        .font(.subheadline)
        .foregroundStyle(theme.foregroundSecondary)   // _800 and _200
}
.padding()
.background(theme.backgroundSecondary)                // _100 and _800
.padding()
.background(theme.backgroundPrimary)                  // _50 and _1000
light mode
Lisbon3 nights from October 12

Every foreground token passes WCAG AA on the page in both modes.

Tokens switch, stops stay fixed#

A stop is one color in both modes, so where a token belongs, it fails quietly in dark mode. The first line uses a stop, the second a token:

swift
let theme = Color.proOrange

VStack(alignment: .leading) {
    Text("3 nights from October 12")
        .foregroundStyle(theme._800.toColor())   // 8.72:1 in light mode, 1.79:1 in dark
    Text("3 nights from October 12")
        .foregroundStyle(theme.foregroundSecondary)   // 8.72:1 and 11.48:1
}
.padding()
.background(theme.backgroundPrimary)
light mode
3 nights from October 123 nights from October 12

Use a stop only when a color should look the same in both modes, such as a logo. A contrast check that reads a token sees only one of its stops; contrastRatio(to:method:) shows how to check both.

Dark backgrounds keep a tint of their hue#

swift
let theme = Color.proSky

VStack(alignment: .leading, spacing: 4) {
    Text("Reykjavík")
        .font(.headline)
        .foregroundStyle(theme.foregroundPrimary)
    Text("3 nights from October 12")
        .font(.subheadline)
        .foregroundStyle(theme.foregroundSecondary)
}
.padding()
.background(theme.backgroundPrimary)   // _1000 in dark mode, a very dark blue
.border(theme.outlineSecondary)
.padding()
.background(Color.backgroundPrimary)   // black in dark mode
light mode
Reykjavík3 nights from October 12

A family's backgrounds come from its own ramp, so the Reykjavík card stays blue in the dark. For a neutral page, use Color.backgroundPrimary, which is black. Every family's foreground tokens pass AA on the gray background tokens too. What does equal lightness cost? explains the tint.

Your own light and dark pairs#

Color(light:dark:) turns any two colors into one that switches with the mode, the way every token does:

swift
// Color(light:dark:) takes two Colors, so each stop needs toColor().
let highlight = Color(
    light: Color.proYellow._200.toColor(),
    dark: Color.proYellow._800.toColor()
)
let ink = Color(light: Color(hex: "#1A1A2E"), dark: .white)

Text("Your flight to Kyoto boards at 14:30.")
    .foregroundStyle(ink)
    .frame(maxWidth: .infinity, alignment: .leading)
    .padding()
    .background(highlight)
light mode
Your flight to Kyoto boards at 14:30.

The text measures 11.47:1 in light mode and 9.52:1 in dark. The result resolves when SwiftUI draws it, so it follows .preferredColorScheme(.dark) without your code reading colorScheme. UIKit and AppKit get the same initializer, as UIColor(light:dark:) and NSColor(light:dark:).

Color functions follow the mode#

Color functions such as soften() work out their result each time a color is drawn, so a function applied to a token keeps adapting:

swift
let subtitle = Color.proOrange.foregroundSecondary   // _800 in light mode, _200 in dark

VStack(alignment: .leading, spacing: 4) {
    Text("3 nights from October 12")
        .font(.subheadline)
        .foregroundStyle(subtitle)
    Text("Flights included")
        .font(.footnote)
        .foregroundStyle(subtitle.soften())   // _750 and _250: quieter in both modes
}
.padding()
.background(Color.proOrange.backgroundPrimary)
light mode
3 nights from October 12Flights included

soften() moves toward the background, so it's quieter in both modes. lighten() would give _750 and _150 here: quieter on a light page, louder on a dark one. Harmonies and gradients of tokens adapt too.

Increase Contrast and watchOS#

Tokens don't change with Increase Contrast; high contrast modes shows how to strengthen them when it's on. Apple Watch always draws in dark mode, so tokens use their dark stops there, as watchOS explains.

Sources#

Next steps

  • 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.
  • Color(light:dark:) Make any color switch between light and dark mode, the way every token does.
  • How accessible is it? What the palette guarantees for contrast in every hue and mode, and what you still need to check.