Skip to content
ColorTokensKitby Penguin Design Ventures
Contents
How-to

Color theory

Pair stops that always read, and pick secondary hues that match your brand, in both modes.

Updated View as Markdown

Color theory here comes down to two questions: which colors read clearly on each other, and which hues sit well together. A counting rule answers the first and harmonies the second, in every hue and in both modes. The semantic tokens already pair up for you. For stops you pick yourself, count them: text 12 stops from its background passes WCAG AA in every hue.

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
    Text("Beach")
        .font(.subheadline)
        .padding(.horizontal, 6)
        .foregroundStyle(theme.foregroundPrimary)
        .background(theme.backgroundTertiary)         // _200 and _700
}
.padding()
.background(theme.backgroundPrimary)                  // _50 and _1000
light mode
Lisbon3 nights from October 12Beach

Every foreground token passes AA on backgroundPrimary and backgroundSecondary in both modes. On backgroundTertiary, only foregroundPrimary passes in both, so the tag uses it. For hues that go with your brand, see secondary and tertiary colors.

The distance rule#

When you pick stops yourself, for a badge, a chart or a custom control, count the stops between the text and its background:

light mode
9 stops apart, 3.09:112 stops apart, 5.11:114 stops apart, 7.29:1
  • 9 stops apart reaches 3:1, for icons, control borders and large text.
  • 12 stops apart passes AA for body text, which needs 4.5:1.
  • 14 stops apart passes AAA, which needs 7:1.

These are the lowest ratios anywhere in the palette, and they hold between families too: blue text on a gold background follows the same rule. On the translucent surface tokens, measure against the color the reader sees.

Drag the text and background stops to measure the pair in every family:

Try it / One stop pair, every family
Text · drag across_700
_50_1000
Background · drag across_100
_50_1000

The 36 hues: 5.53 to 5.53:1Gray has its own lightness ladder, so it can differ.

proGray_700 on _100passes AA,8.73:1APCA 86
proPink_700 on _100passes AA,5.53:1APCA 70
proRuby_700 on _100passes AA,5.53:1APCA 70
proRed_700 on _100passes AA,5.53:1APCA 70
proTomato_700 on _100passes AA,5.53:1APCA 70
proCoral_700 on _100passes AA,5.53:1APCA 70
proOrange_700 on _100passes AA,5.53:1APCA 70
proBrown_700 on _100passes AA,5.53:1APCA 70
proAmber_700 on _100passes AA,5.53:1APCA 70.1
proGold_700 on _100passes AA,5.53:1APCA 70.1
proMustard_700 on _100passes AA,5.53:1APCA 70.2
proYellow_700 on _100passes AA,5.53:1APCA 70.2
proOlive_700 on _100passes AA,5.53:1APCA 70.2
proChartreuse_700 on _100passes AA,5.53:1APCA 70.3
proLime_700 on _100passes AA,5.53:1APCA 70.3
proGrass_700 on _100passes AA,5.53:1APCA 70.2
proGreen_700 on _100passes AA,5.53:1APCA 70.1
proJade_700 on _100passes AA,5.53:1APCA 69.7
proEmerald_700 on _100passes AA,5.53:1APCA 69.6
proMint_700 on _100passes AA,5.53:1APCA 69.5
proTeal_700 on _100passes AA,5.53:1APCA 69.6
proTurquoise_700 on _100passes AA,5.53:1APCA 69.6
proCyan_700 on _100passes AA,5.53:1APCA 69.6
proCerulean_700 on _100passes AA,5.53:1APCA 69.5
proSky_700 on _100passes AA,5.53:1APCA 69.6
proAzure_700 on _100passes AA,5.53:1APCA 69.8
proBlue_700 on _100passes AA,5.53:1APCA 70.1
proCobalt_700 on _100passes AA,5.53:1APCA 70.2
proIndigo_700 on _100passes AA,5.53:1APCA 70.3
proIris_700 on _100passes AA,5.53:1APCA 70.3
proViolet_700 on _100passes AA,5.53:1APCA 70.3
proGrape_700 on _100passes AA,5.53:1APCA 70.3
proPurple_700 on _100passes AA,5.53:1APCA 70.3
proOrchid_700 on _100passes AA,5.53:1APCA 70.2
proPlum_700 on _100passes AA,5.53:1APCA 70.2
proMagenta_700 on _100passes AA,5.53:1APCA 70.1
proRose_700 on _100passes AA,5.53:1APCA 70.1

The 36 hues land within 0.01 of each other for any pair, because every hue shares each stop's lightness. Gray has its own ladder, and 9 or more stops apart it measures higher.

What each range of stops is for#

The rule gives each part of a family a job. In light mode:

50
100
150
200
250
300
350
400
450
500
550
600
650
700
750
800
850
900
950
1000
↑ backgrounds
↑ dividers
↑ fills
↑ text
Color.proBlue, OKLCH hue 250°

Dark mode swaps the ends. Yellow turns olive from _600 down, so use proGold for dark warm text. Neighboring hues such as pink, ruby and red look alike at the light stops, so pick hues further apart to tell categories apart. What does equal lightness cost? explains both.

Borders people need to see#

swift
HStack {
    ForEach(["Beach", "City", "Food"], id: \.self) { tag in
        Button(tag) {}
            .buttonStyle(.plain)
            .padding(.vertical, 6)
            .padding(.horizontal, 12)
            .foregroundStyle(brand.foregroundPrimary)
            .border(brand.outlinePrimary)       // _600 in light mode, _350 in dark
    }
}
.padding()
.background(brand.backgroundPrimary)            // _50 and _1000
light mode
BeachCityFood

The edge of a control needs 3:1 under WCAG's rule for non-text contrast. outlinePrimary reaches it on every background token in both modes, in every hue, so the tags use it. outlineSecondary and outlineTertiary are faint on purpose, for dividers and card edges, which WCAG sets no minimum for. For a stop of your own, pick one at least 9 from the background.

Text on a colored fill#

swift
Text("Book now")
    .font(.headline)
    .padding(.vertical, 10)
    .padding(.horizontal, 16)
    .foregroundStyle(brand.invertedForegroundPrimary)   // _50 in light mode, _1000 in dark
    .background(brand.invertedBackgroundTertiary)       // _650 and _250: 5.11:1 and 10.20:1
light mode
Book now

The inverted tokens pair a fill with text that reads on it, in both modes. For a fill that stays the same in both modes, white text needs _600 or darker and black text _550 or lighter, so every stop has a text color that passes AA, in every hue. Gray switches a stop earlier: white from proGray._550, black up to proGray._500.

Secondary and tertiary colors#

These sit beside your brand color, on tags, badges and categories. Call a harmony on your brand's family, and you get whole families at other hues, each with every token:

swift
let secondary = brand.triad[1]   // your brand's family, turned 120° around the hue wheel
let tertiary = brand.triad[2]    // and turned 240°

HStack {
    Text("Beach")
        .padding(8)
        .foregroundStyle(brand.foregroundPrimary)   // _1000 in light mode, _50 in dark
        .background(brand.backgroundTertiary)       // _200 in light mode, _700 in dark
    Text("City")
        .padding(8)
        .foregroundStyle(secondary.foregroundPrimary)
        .background(secondary.backgroundTertiary)
    Text("Food")
        .padding(8)
        .foregroundStyle(tertiary.foregroundPrimary)
        .background(tertiary.backgroundTertiary)
}
light mode
BeachCityFood

A harmony changes only the hue, so every tag has the same contrast: 11.48:1 in light mode and 6.10:1 in dark. For lighter and darker steps of one hue, use a lightness set instead.

Other hues that go with yours#

Each harmony turns your color around the hue wheel by set angles and keeps its lightness. Try your color and each harmony below, then set "See as" to "Lightness only": every color in the harmony turns the same gray.

Try it / Harmonies
Color

Every OKLCH hue at this color's lightness

0°90°180°270°360°
light mode
#63a7fdproBlue._400
#ed8295proRuby._400
#8ab24dproLime._400
dark mode
#63a7fdproBlue._400
#ed8295proRuby._400
#8ab24dproLime._400
swift
let color = Color.proBlue._400.toColor()
color.triad
HarmonyHues, from yoursGood for
complement180°One accent that stands apart
triad0°, 120°, 240°A primary, secondary and tertiary color
splitComplement()0°, 150°, 210°Two accents either side of the complement
analogous()−30°, 0°, 30°Close neighbors for banners and illustrations
square0°, 90°, 180°, 270°Four categories
tetrad()0°, 60°, 180°, 240°Four colors in two opposite pairs

An accent that stands apart#

The complement is the hue opposite yours, so it stands out by hue alone. Save it for the one thing that has to catch the eye, such as a price:

swift
let accent = brand.complement   // your brand's family, turned 180°

HStack(spacing: 16) {
    VStack(alignment: .leading, spacing: 4) {
        Text("Lisbon")
            .font(.headline)
            .foregroundStyle(brand.foregroundPrimary)
        Text("3 nights from October 12")
            .font(.subheadline)
            .foregroundStyle(brand.foregroundSecondary)
    }
    Text("€480")
        .padding(8)
        .foregroundStyle(accent.foregroundPrimary)   // _1000 in light mode, _50 in dark
        .background(accent.backgroundTertiary)       // _200 in light mode, _700 in dark
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(brand.backgroundPrimary)                 // _50 in light mode, _1000 in dark
light mode
Lisbon3 nights from October 12
€480

Every token in the new hues#

Name your secondary and tertiary families once, next to your brand, and every token works on them in both modes:

swift
extension Color {
    static let brand = ProTheme(hex: "#00B386")
    static let brandSecondary = brand.triad[1]
    static let brandTertiary = brand.triad[2]
}

Lighter and darker steps of one hue#

Show ordered data in one color, such as cheap to pricey days on a fare calendar, with a lightness set. monochromatic() gives five stops of a color's hue, from lightest to darkest. It uses only the hue, so a token gives the same five in both modes:

swift
let levels = brand.foregroundPrimary.monochromatic()   // _50, _300, _550, _750, _1000

HStack(spacing: 4) {
    ForEach(levels.indices, id: \.self) { index in
        Rectangle()
            .fill(levels[index])
            .frame(width: 24, height: 24)
            .border(brand.outlineSecondary)
    }
}
light mode

The steps differ in lightness, so the scale still reads in grayscale, which a harmony's colors don't. The lightest step nearly vanishes on a white page, at 1.09:1, and the darkest on a black one, at 1.23:1, so each cell gets a border.

tints(count:) gives lighter versions of a color, one stop apart and nearest first, and shades(count:) darker ones. Pick a color and change the counts below:

Try it / Lightness sets
Color
light mode

monochromatic

tints

shades

dark mode

monochromatic

tints

shades

swift
let color = Color.proGreen._400.toColor()

color.monochromatic()
color.tints()
color.shades()

How harmonies behave#

A harmony turns each color with rotateHue(by:), so a palette color lands on the same stop at its new hue. The built-in hues sit every 10°, and every default harmony turns by a multiple of 10°. So a built-in family's harmony is made of built-in families: Color.proBlue.triad is blue, ruby and lime. Gray has no hue, so a gray's harmony is all gray.

Because only hue separates them, a harmony's colors match in grayscale, and some look alike to people with a color vision deficiency. When color carries meaning, add a label or an icon, as using tokens in charts shows.

monochromatic(count:) spreads its colors evenly over the whole ramp, so your color may not be one of them. On a token, tints are lighter in both modes, like lighten(): quieter on a white page, louder on a black one. For quieter in both, use soften(by:).

To pick a harmony at runtime, such as from a picker, pass it to harmony(_:). Each harmony's page has its signature and options.

See also

  • Setting up themes Recolor a view, a screen or your whole app from one value, with the same contrast in every hue.
  • How accessible is it? What the palette guarantees for contrast in every hue and mode, and what you still need to check.
  • .contrastRatio(to:method:) Check WCAG 2 and APCA contrast in Swift, and keep your color pairs passing with a unit test.
  • Building for interaction states Give buttons and rows hover, pressed, selected and disabled colors that stay readable in both modes.
  • Why OKLCH? Every hue looks evenly matched at each stop, so you can swap families without rechecking contrast.