# Color theory

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

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](https://colortokenskit.com/getting-started/semantic-tokens/index.md) already pair up for you. For [stops](https://colortokenskit.com/basics/what-are-tokens/index.md) 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
```

What this draws, with `Color.proOrange`:

| Color                 | light mode                             | dark mode                              |
| --------------------- | -------------------------------------- | -------------------------------------- |
| `backgroundPrimary`   | color(display-p3 0.9936 0.9528 0.9271) | #301404                                |
| `foregroundPrimary`   | #301404                                | color(display-p3 0.9936 0.9528 0.9271) |
| `foregroundSecondary` | #6e3612                                | color(display-p3 0.978 0.79 0.6716)    |
| `backgroundTertiary`  | color(display-p3 0.978 0.79 0.6716)    | #90491a                                |

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](#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:

What this draws, with `#00B386`:

| Color  | light mode                             | dark mode                              |
| ------ | -------------------------------------- | -------------------------------------- |
| `_50`  | #eaf8f2                                | #eaf8f2                                |
| `_500` | color(display-p3 0.1935 0.624 0.4631)  | color(display-p3 0.1935 0.624 0.4631)  |
| `_650` | color(display-p3 0.1368 0.4655 0.3425) | color(display-p3 0.1368 0.4655 0.3425) |
| `_750` | color(display-p3 0.0977 0.3637 0.2645) | color(display-p3 0.0977 0.3637 0.2645) |

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

| Family          | `_700` on `_100` | WCAG AA     |
| --------------- | ---------------- | ----------- |
| `proGray`       | 8.73:1           | ✓ passes AA |
| `proPink`       | 5.53:1           | ✓ passes AA |
| `proRuby`       | 5.53:1           | ✓ passes AA |
| `proRed`        | 5.53:1           | ✓ passes AA |
| `proTomato`     | 5.53:1           | ✓ passes AA |
| `proCoral`      | 5.53:1           | ✓ passes AA |
| `proOrange`     | 5.53:1           | ✓ passes AA |
| `proBrown`      | 5.53:1           | ✓ passes AA |
| `proAmber`      | 5.53:1           | ✓ passes AA |
| `proGold`       | 5.53:1           | ✓ passes AA |
| `proMustard`    | 5.53:1           | ✓ passes AA |
| `proYellow`     | 5.53:1           | ✓ passes AA |
| `proOlive`      | 5.53:1           | ✓ passes AA |
| `proChartreuse` | 5.53:1           | ✓ passes AA |
| `proLime`       | 5.53:1           | ✓ passes AA |
| `proGrass`      | 5.53:1           | ✓ passes AA |
| `proGreen`      | 5.53:1           | ✓ passes AA |
| `proJade`       | 5.53:1           | ✓ passes AA |
| `proEmerald`    | 5.53:1           | ✓ passes AA |
| `proMint`       | 5.53:1           | ✓ passes AA |
| `proTeal`       | 5.53:1           | ✓ passes AA |
| `proTurquoise`  | 5.53:1           | ✓ passes AA |
| `proCyan`       | 5.53:1           | ✓ passes AA |
| `proCerulean`   | 5.53:1           | ✓ passes AA |
| `proSky`        | 5.53:1           | ✓ passes AA |
| `proAzure`      | 5.53:1           | ✓ passes AA |
| `proBlue`       | 5.53:1           | ✓ passes AA |
| `proCobalt`     | 5.53:1           | ✓ passes AA |
| `proIndigo`     | 5.53:1           | ✓ passes AA |
| `proIris`       | 5.53:1           | ✓ passes AA |
| `proViolet`     | 5.53:1           | ✓ passes AA |
| `proGrape`      | 5.53:1           | ✓ passes AA |
| `proPurple`     | 5.53:1           | ✓ passes AA |
| `proOrchid`     | 5.53:1           | ✓ passes AA |
| `proPlum`       | 5.53:1           | ✓ passes AA |
| `proMagenta`    | 5.53:1           | ✓ passes AA |
| `proRose`       | 5.53:1           | ✓ passes AA |

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:

`proBlue`, lightest to darkest: #eff6ff, #ddecff, #cbe1fe, #b8d7fe, #a4cbfe, #8fc0fe, #7ab4fd, #63a7fd, #579aef, #508edc, #4882ca, #4176b8, #3a69a5, #335e94, #2c5282, #254771, #1e3b60, #183150, #122640, #0b1c32. Marked: `_50` (backgrounds), `_200` (dividers), `_500` (fills), `_800` (text).

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?](https://colortokenskit.com/under-the-hood/trade-offs/index.md) 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
```

What this draws, with `#00B386`:

| Color               | light mode                             | dark mode                              |
| ------------------- | -------------------------------------- | -------------------------------------- |
| `backgroundPrimary` | #eaf8f2                                | color(display-p3 0.0176 0.1298 0.086)  |
| `foregroundPrimary` | color(display-p3 0.0176 0.1298 0.086)  | #eaf8f2                                |
| `outlinePrimary`    | color(display-p3 0.1547 0.5182 0.3824) | color(display-p3 0.3144 0.7788 0.5953) |

The edge of a control needs 3:1 under WCAG's rule for [non-text contrast](https://www.w3.org/TR/WCAG22/#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
```

What this draws, with `#00B386`:

| Color                        | light mode                             | dark mode                             |
| ---------------------------- | -------------------------------------- | ------------------------------------- |
| `invertedForegroundPrimary`  | #eaf8f2                                | color(display-p3 0.0176 0.1298 0.086) |
| `invertedBackgroundTertiary` | color(display-p3 0.1368 0.4655 0.3425) | #79dab5                               |

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)
}
```

What this draws, with `#00B386`:

| Color                           | light mode                             | dark mode                              |
| ------------------------------- | -------------------------------------- | -------------------------------------- |
| `foregroundPrimary`             | color(display-p3 0.0176 0.1298 0.086)  | #eaf8f2                                |
| `backgroundTertiary`            | #9ce2c5                                | color(display-p3 0.1149 0.4143 0.3028) |
| `hue:286.99.foregroundPrimary`  | #1c1834                                | color(display-p3 0.9574 0.9567 0.9992) |
| `hue:286.99.backgroundTertiary` | color(display-p3 0.8192 0.8118 0.9966) | #5c539a                                |
| `hue:46.99.foregroundPrimary`   | #311405                                | color(display-p3 0.9945 0.9523 0.9287) |
| `hue:46.99.backgroundTertiary`  | color(display-p3 0.9816 0.7877 0.6793) | #91481f                                |

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](#lighter-and-darker-steps-of-one-hue) 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.

A harmony explorer marks each harmony's hues on a strip of every OKLCH hue. By default it shows the triad of `Color.proBlue._400`: #63a7fd, #ed8295, #8ab24d. [Try it on the live page](https://colortokenskit.com/advanced/color-theory/).

| Harmony                                                                         | Hues, from yours    | Good for                                      |
| ------------------------------------------------------------------------------- | ------------------- | --------------------------------------------- |
| [`complement`](https://colortokenskit.com/api/complement/index.md)              | 180°                | One accent that stands apart                  |
| [`triad`](https://colortokenskit.com/api/triad/index.md)                        | 0°, 120°, 240°      | A primary, secondary and tertiary color       |
| [`splitComplement()`](https://colortokenskit.com/api/split-complement/index.md) | 0°, 150°, 210°      | Two accents either side of the complement     |
| [`analogous()`](https://colortokenskit.com/api/analogous/index.md)              | −30°, 0°, 30°       | Close neighbors for banners and illustrations |
| [`square`](https://colortokenskit.com/api/square/index.md)                      | 0°, 90°, 180°, 270° | Four categories                               |
| [`tetrad()`](https://colortokenskit.com/api/tetrad/index.md)                    | 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
```

What this draws, with `#00B386`:

| Color                           | light mode                             | dark mode                             |
| ------------------------------- | -------------------------------------- | ------------------------------------- |
| `backgroundPrimary`             | #eaf8f2                                | color(display-p3 0.0176 0.1298 0.086) |
| `foregroundPrimary`             | color(display-p3 0.0176 0.1298 0.086)  | #eaf8f2                               |
| `foregroundSecondary`           | color(display-p3 0.0797 0.3134 0.2262) | #9ce2c5                               |
| `hue:346.99.foregroundPrimary`  | #2f1222                                | #fff1f8                               |
| `hue:346.99.backgroundTertiary` | color(display-p3 0.9671 0.7712 0.879)  | #8d436d                               |

### 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()`](https://colortokenskit.com/api/monochromatic/index.md) gives five [stops](https://colortokenskit.com/basics/what-are-tokens/index.md) 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)
    }
}
```

What this draws, with `#00B386`:

| Color              | light mode                             | dark mode                              |
| ------------------ | -------------------------------------- | -------------------------------------- |
| `_50`              | #eaf8f2                                | #eaf8f2                                |
| `outlineSecondary` | #9ce2c5                                | color(display-p3 0.0797 0.3134 0.2262) |
| `_300`             | #4cd2a5                                | #4cd2a5                                |
| `_550`             | color(display-p3 0.1717 0.5709 0.4222) | color(display-p3 0.1717 0.5709 0.4222) |
| `_750`             | color(display-p3 0.0977 0.3637 0.2645) | color(display-p3 0.0977 0.3637 0.2645) |
| `_1000`            | color(display-p3 0.0176 0.1298 0.086)  | color(display-p3 0.0176 0.1298 0.086)  |

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:)`](https://colortokenskit.com/api/tints/index.md) gives lighter versions of a color, one stop apart and nearest first, and [`shades(count:)`](https://colortokenskit.com/api/shades/index.md) darker ones. Pick a color and change the counts below:

The live page shows lightness sets for any color. For `Color.proGreen._400`, `monochromatic()` gives #eff7ef, #8acd8f, #4b9053, #2e5c33, #0c210f; `tints()` gives #71c47a, #8acd8f, #a1d6a4; `shades()` gives #5aab63, #539d5b, #4b9053. [Try it on the live page](https://colortokenskit.com/advanced/color-theory/).

## How harmonies behave

A harmony turns each color with [`rotateHue(by:)`](https://colortokenskit.com/api/rotate-hue/index.md), 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](https://colortokenskit.com/advanced/charts/index.md) 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()`](https://colortokenskit.com/api/lighten/index.md): quieter on a white page, louder on a black one. For quieter in both, use [`soften(by:)`](https://colortokenskit.com/api/soften/index.md).

To pick a harmony at runtime, such as from a picker, pass it to [`harmony(_:)`](https://colortokenskit.com/api/harmony/index.md). Each harmony's page has its signature and options.

## See also

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

---

From ColorTokensKit, by Penguin Design Ventures: https://colortokenskit.com/advanced/color-theory/ (updated September 26, 2026).
