# ProTheme

Make a theme from a hex, a hue or OKLCH numbers, and every stop and token keeps its contrast.

Give a view a `ProTheme` and it can wear any hue: the theme holds 20 stops, from `_50` to `_1000`, and the 20 tokens `ColorTokens.swift` adds, such as `foregroundPrimary`. Every way to make a theme returns one, from `Color.proOrange` to `ProTheme(hex:)`, so one view works with all of them:

```swift
struct TripCard: View {
    let theme: ProTheme

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text("Lisbon")
                .font(.headline)
                .foregroundStyle(theme.foregroundPrimary)
            Text("3 nights from October 12")
                .font(.subheadline)
                .foregroundStyle(theme.foregroundSecondary)
        }
        .padding()
        .background(theme.backgroundSecondary)
    }
}

TripCard(theme: Color.proOrange)
TripCard(theme: ProTheme(hex: "#00B386"))
```

What this draws:

| Color                 | Color.proOrange light mode             | Color.proOrange dark mode              | #00B386 light mode                     | #00B386 dark mode                      |
| --------------------- | -------------------------------------- | -------------------------------------- | -------------------------------------- | -------------------------------------- |
| `backgroundSecondary` | color(display-p3 0.9885 0.9007 0.8455) | #6e3612                                | #d2f1e3                                | color(display-p3 0.0797 0.3134 0.2262) |
| `foregroundPrimary`   | #301404                                | color(display-p3 0.9936 0.9528 0.9271) | color(display-p3 0.0176 0.1298 0.086)  | #eaf8f2                                |
| `foregroundSecondary` | #6e3612                                | color(display-p3 0.978 0.79 0.6716)    | color(display-p3 0.0797 0.3134 0.2262) | #9ce2c5                                |

## Where a theme comes from

| You have                | Use                                                                                                                                                               |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A hue with a name       | [`Color.proOrange`](https://colortokenskit.com/api/named-themes/index.md) and the other ready-made themes                                                         |
| Your brand's hex        | [`ProTheme(hex:)`](#from-a-hex-color)                                                                                                                             |
| A hue number            | [`ProTheme.primary(forHue:)`](#from-a-hue)                                                                                                                        |
| A color's OKLCH numbers | [`ProTheme(oklch:)`](#from-oklch-numbers)                                                                                                                         |
| Another theme           | A harmony such as [`.complement`](https://colortokenskit.com/api/complement/index.md), or [`.rotateHue(by:)`](https://colortokenskit.com/api/rotate-hue/index.md) |

## What a theme holds

- **Stops.** `theme._50` to `theme._1000`, and `theme.allStops` in that order. Each stop is itself a `ProTheme`, for one fixed color. [Stops](https://colortokenskit.com/api/stops/index.md) has the details.
- **Tokens.** `ColorTokens.swift` extends `ProTheme` with 20 tokens, each a `Color` that picks one stop in light mode and another in dark. [Understanding semantic tokens](https://colortokenskit.com/getting-started/semantic-tokens/index.md) explains each one.
- **Its own color.** `toColor()` is the color the theme was made from: your exact hex for `ProTheme(hex:)`, and the `_450` stop for a ready-made theme. `l`, `c`, `h` and `alpha` are its OKLCH numbers.

## From a hex color

Give `ProTheme(hex:)` your brand color and you get a theme like the built-in ones: 20 stops and every token, with the same contrast. It takes a hex string and returns a `ProTheme` at your color's hue, which keeps your exact hex as its own color.

```swift
let brand = ProTheme(hex: "#00B386")

Text("Book now")
    .padding()
    .foregroundStyle(brand.invertedForegroundPrimary)   // _50 in light mode, _1000 in dark
    .background(brand.invertedBackgroundTertiary)       // _650 in light mode, _250 in dark
```

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                               |

Type your own brand's hex to see its theme:

The live page turns any hex into its theme. `ProTheme(hex: "#00B386")` sits at OKLCH hue 167°, and its stops, `_50` to `_1000`, in Display P3 hex, are: #edf8f2, #d8f0e4, #c3e8d6, #abe0c7, #91d8b8, #74cfa8, #50c798, #44ba8c, #35ad80, #319f76, #2c926c, #278462, #237757, #1d6a4d, #195d43, #14503a, #104330, #0c3827, #082c1e, #042116. [Try it on the live page](https://colortokenskit.com/api/protheme/).

### Keeping your exact brand color

The theme's stops sit at the palette's lightness levels, and your hex usually falls between two of them: #00B386 is as light as a stop between `_400` and `_450`. So no stop is your exact hex, and that's what keeps the contrast right.

Your hex isn't lost, though. `brand.toColor()` is still exactly #00B386, for a logo or anywhere your brand guide asks for the exact color. Draw everything else with tokens.

### A theme for each customer

When the color comes from a server, such as a partner's brand in a white-label app, build each theme once and pass it into your views:

```swift
let partners = ["Surf Club": "#0088FF", "Sunset Tours": "#FF6A3D"]
let themes = partners.mapValues { ProTheme(hex: $0) }

CardView(theme: themes["Surf Club"] ?? Color.proBlue)
```

Check each string first: a malformed hex doesn't fail, it gives the wrong color.

### How a hex is read

The ramp takes your color's OKLCH [hue](https://colortokenskit.com/reference/glossary/index.md#hue): #00B386 is 167°, between `proJade` and `proEmerald`. Its stops get the same lightness and chroma as every built-in theme's, so tokens have the same contrast. A gray, such as #808080, gives the gray theme.

The string can be `#RGB`, `#RRGGBB` or `#AARRGGBB`, and the `#` is optional. Eight digits put alpha first, so a hex with alpha last gives the wrong color. Any other length doesn't fail either: it reads as a nearly transparent, nearly black color, and a theme near `proOlive`. So check strings you don't control.

The hex you pass is sRGB, and the stops are [Display P3](https://colortokenskit.com/reference/glossary/index.md#display-p3), so some are more vivid than any hex can say. `ProTheme(hex:)` is the same as `ProTheme(oklch: OKLCHColor(hex:))`.

```swift
public extension ProTheme {
    init(hex: String)
}
```

| Parameter | Type     | What it takes                                                 |
| --------- | -------- | ------------------------------------------------------------- |
| `hex`     | `String` | `#RGB`, `#RRGGBB` or `#AARRGGBB` in sRGB, with or without `#` |

It returns a `ProTheme` at the color's OKLCH hue, or the gray theme for a gray. Its stops and tokens come from that hue's ramp, and its own color, `toColor()`, is the color you passed.

## From a hue

When you want a hue between the named themes, `ProTheme.primary(forHue:)` builds it, with 20 stops, every token and the same contrast. It takes an OKLCH hue in degrees and returns a `ProTheme` whose own color is its `_450` stop.

```swift
let theme = ProTheme.primary(forHue: 167)   // between proJade (160°) and proEmerald (170°)

Text("Lisbon")
    .font(.headline)
    .foregroundStyle(theme.foregroundPrimary)
    .padding()
    .background(theme.backgroundSecondary)
```

What this draws, with `ProTheme.primary(forHue: 167)`:

| Color                 | light mode                            | dark mode                              |
| --------------------- | ------------------------------------- | -------------------------------------- |
| `backgroundSecondary` | #d2f1e3                               | color(display-p3 0.0797 0.3134 0.2262) |
| `foregroundPrimary`   | color(display-p3 0.0176 0.1298 0.086) | #eaf8f2                                |

Drag the hue to build the ramp for any number:

A ramp explorer builds the ramp for any OKLCH hue. At hue 167, `ProTheme.primary(forHue: 167)` has these stops, `_50` to `_1000`, in Display P3 hex: #edf8f2, #d8f0e4, #c3e8d6, #abe0c7, #91d8b8, #74cfa8, #50c798, #44ba8c, #35ad80, #319f76, #2c926c, #278462, #237757, #1d6a4d, #195d43, #14503a, #104330, #0c3827, #082c1e, #042116. [Try it on the live page](https://colortokenskit.com/api/protheme/).

### One theme per category

Every named theme is a hue: `Color.proBlue` is `ProTheme.primary(forHue: 250)`. So when you need a theme for each of several categories, spread them around the wheel and each gets its own theme with the same contrast:

```swift
let categories = ["Stays", "Flights", "Tours", "Food", "Shopping"]
let themes = categories.indices.map { index in
    ProTheme.primary(forHue: Double(index) * 360 / Double(categories.count))
}
```

Five categories land 72° apart, at 0°, 72°, 144°, 216° and 288°. [Using tokens in charts](https://colortokenskit.com/advanced/charts/index.md) covers colors that must stay apart in a chart.

### Gray at any hue

With `isGrayscale: true`, the hue is ignored and you get the gray theme:

```swift
let neutral = ProTheme.primary(forHue: 0, isGrayscale: true)   // the same as Color.proGray
```

That's useful when a setting picks between a hue and no hue at all.

### The ramp as color values

For the stops as numbers, to export them or feed another tool, ask the ramp generator. It gives the same 20 stops a theme at that hue has, lightest first:

```swift
let ramp = ColorRampGenerator().getOKLCHColorRamp(forHue: 167)   // 20 OKLCHColor values, _50 first
let lchRamp = ColorRampGenerator().getColorRamp(forHue: 167)      // the same stops as CIELab LCH
```

Every generator shares one cache, so making one is cheap. The `steps` parameter doesn't change the count: a ramp always has 20 stops.

### How a hue is read

The hue is an OKLCH hue, not the HSL hue of a design tool's color picker, so the same number lands somewhere else: HSL's 240° blue is about 264° in OKLCH. To start from a color, use [`ProTheme(hex:)`](#from-a-hex-color), which works out the hue for you.

Any number works. It wraps around the wheel, so 370 is `proRuby`'s 10° and −10 is `proRose`'s 350°, and it's rounded to 0.01°.

Every hue gets the same lightness and chroma at each stop. A few can't show that much chroma in Display P3 at some stops and keep 98% of what they can, at the same lightness, so their contrast still matches. [How did we choose and build these colors?](https://colortokenskit.com/basics/how-the-colors-were-built/index.md) shows where.

The theme's own color, `toColor()`, is its `_450`. The ramp for each hue is built the first time you ask for it and then cached, so building a theme again is cheap.

```swift
public extension ProTheme {
    static func primary(forHue hue: Double, isGrayscale: Bool = false) -> ProTheme
}

public class ColorRampGenerator {
    public init()
    public func getOKLCHColorRamp(
        forHue targetHue: Double, steps: Int? = nil, isGrayscale: Bool = false
    ) -> [OKLCHColor]
    public func getColorRamp(
        forHue targetHue: Double, steps: Int? = nil, isGrayscale: Bool = false
    ) -> [LCHColor]
}
```

| Parameter     | Type     | Default | What it takes                                                 |
| ------------- | -------- | ------- | ------------------------------------------------------------- |
| `hue`         | `Double` | none    | An OKLCH hue in degrees; numbers outside 0 to 360 wrap around |
| `isGrayscale` | `Bool`   | `false` | `true` gives the gray theme and ignores `hue`                 |

It returns a `ProTheme` whose stops and tokens come from that hue's ramp, and whose own color is the `_450` stop. The generator's two functions take the same hue and return that ramp's 20 stops, `_50` first, as `OKLCHColor` or `LCHColor` values.

## From OKLCH numbers

When you have a color's OKLCH numbers, from a design tool or your own math, `ProTheme(oklch:)` turns it into a theme with 20 stops and every token. It takes an `OKLCHColor` and returns a `ProTheme` at that color's hue, which keeps the color you passed as its own.

```swift
let brand = ProTheme(oklch: OKLCHColor(l: 0.68, c: 0.14, h: 167))

Text("Book now")
    .padding()
    .foregroundStyle(brand.invertedForegroundPrimary)   // _50 in light mode, _1000 in dark
    .background(brand.invertedBackgroundTertiary)       // _650 in light mode, _250 in dark
```

What this draws, with `ProTheme.primary(forHue: 167)`:

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

### Starting from a color you already have

Any color becomes an `OKLCHColor` first. `ProTheme(hex:)` is this same call with `OKLCHColor(hex:)`:

```swift
let fromHex = ProTheme(oklch: OKLCHColor(hex: "#00B386"))   // same as ProTheme(hex: "#00B386")
let fromColor = ProTheme(oklch: Color(red: 0, green: 0.7, blue: 0.53).toOKLCH())
```

[`.toOKLCH()`](https://colortokenskit.com/api/to-oklch/index.md) reads any SwiftUI color that way.

### Only the hue sets the stops

The stops come from the hue alone, so two colors at the same hue give the same stops and tokens, however light or vivid each is. Lightness and chroma stay in the theme's own color, `toColor()`:

```swift
let pale = ProTheme(oklch: OKLCHColor(l: 0.9, c: 0.04, h: 167))
let deep = ProTheme(oklch: OKLCHColor(l: 0.4, c: 0.1, h: 167))

pale.foregroundPrimary   // the same color as deep.foregroundPrimary
```

The hue wraps, so `h: 527` is the same as `h: 167`. The stops and tokens match [`ProTheme.primary(forHue:)`](#from-a-hue) at the same hue. Only the theme's own color differs: yours, where `primary(forHue:)` uses the hue's `_450` stop.

```swift
public init(oklch: OKLCHColor)
```

| Parameter | Type         | What it takes                                                              |
| --------- | ------------ | -------------------------------------------------------------------------- |
| `oklch`   | `OKLCHColor` | Any color. Its hue gives the ramp, or a chroma of 0.005 or less gives gray |

It returns a `ProTheme` whose stops and tokens come from the color's hue, and whose own color, `toColor()`, is the color you passed.

## Passing a theme around

`ProTheme` is `Hashable` and `Sendable`, so you can keep one in state, use it as a dictionary key or a picker's selection, and send it across tasks:

```swift
let themes: [String: ProTheme] = ["Beach": Color.proCyan, "City": Color.proIndigo]

CardView(theme: themes["Beach"] ?? Color.proBlue)
```

[Letting people choose a theme](https://colortokenskit.com/api/named-themes/index.md#letting-people-choose-a-theme) builds a picker from the ready-made themes.

## What else a theme does

- **Related themes.** [`.complement`](https://colortokenskit.com/api/complement/index.md), [`.triad`](https://colortokenskit.com/api/triad/index.md), [`.analogous()`](https://colortokenskit.com/api/analogous/index.md), [`.splitComplement()`](https://colortokenskit.com/api/split-complement/index.md), [`.tetrad()`](https://colortokenskit.com/api/tetrad/index.md), [`.square`](https://colortokenskit.com/api/square/index.md), [`.harmony(_:)`](https://colortokenskit.com/api/harmony/index.md) and [`.rotateHue(by:)`](https://colortokenskit.com/api/rotate-hue/index.md) return other themes at the same stop.
- **Gradients.** [`.proGradient()`](https://colortokenskit.com/api/pro-gradient/index.md), [`.proRadialGradient()`](https://colortokenskit.com/api/pro-radial-gradient/index.md) and [`.proAngularGradient()`](https://colortokenskit.com/api/pro-angular-gradient/index.md) draw a recipe from the theme's own color.
- **Contrast.** [`.contrastRatio(to:method:)`](https://colortokenskit.com/api/contrast-ratio/index.md) measures the theme's own color against another theme's.
- **Conversions.** [`.toColor()`](https://colortokenskit.com/api/to-color/index.md), [`.toRGB()`](https://colortokenskit.com/api/to-rgb/index.md), [`.toOKLCH()`](https://colortokenskit.com/api/to-oklch/index.md) and [`.toLCH()`](https://colortokenskit.com/api/to-lch/index.md).

The color functions, such as [`.darken(by:)`](https://colortokenskit.com/api/darken/index.md), work on `Color`, so use them on a token or on `toColor()`, not on the theme.

## How it behaves

A stop keeps its theme's ramp, so `theme._600._200` is the same as `theme._200`.

Two themes are equal when they come from the same color and ramp. `ProTheme.primary(forHue: 250) == Color.proBlue` is true, because `proBlue` is built that way.

A color with a chroma of 0.005 or less gives the gray theme, with gray's own lightness ladder, whichever way you make the theme.

`ProColor`, its older name, is the same type, kept as a deprecated alias. [Troubleshooting](https://colortokenskit.com/reference/troubleshooting/index.md#procolor-is-deprecated-renamed-to-protheme) shows how to rename it.

## API

```swift
public struct ProTheme: Hashable, Sendable {
    public init(oklch: OKLCHColor)

    public var l: CGFloat { get }        // OKLCH lightness, 0 to 1
    public var c: CGFloat { get }        // OKLCH chroma
    public var h: CGFloat { get }        // OKLCH hue, 0° to 360°
    public var alpha: CGFloat { get }

    public func toColor() -> Color
    public func toRGB() -> RGBColor
    public func toOKLCH() -> OKLCHColor
    public func toLCH() -> LCHColor
}

public extension ProTheme {
    init(hex: String)
    static func primary(forHue hue: Double, isGrayscale: Bool = false) -> ProTheme

    var allStops: [ProTheme] { get }
    var _50: ProTheme { get }            // and each stop to _1000

    func contrastRatio(to other: ProTheme, method: ContrastMethod = .wcag2) -> CGFloat
}

// In ColorTokens.swift
public extension ProTheme {
    var foregroundPrimary: Color { get }  // and the other 19 tokens
}
```

## Sources

- The library's [ProTheme.swift](https://github.com/metasidd/ColorTokensKit-Swift/blob/main/Sources/ColorTokensKit/ProTheme/ProTheme.swift), which defines the type, its stops and its initializers, including `primary(forHue:isGrayscale:)`, and [ProTheme+Harmonies.swift](https://github.com/metasidd/ColorTokensKit-Swift/blob/main/Sources/ColorTokensKit/Harmonies/ProTheme+Harmonies.swift).
- [Color+Initialization.swift](https://github.com/metasidd/ColorTokensKit-Swift/blob/main/Sources/ColorTokensKit/Platform/SwiftUI/Color+Initialization.swift), which reads the hex string, and [Gamut.swift](https://github.com/metasidd/ColorTokensKit-Swift/blob/main/Sources/ColorTokensKit/Services/Ramps/Gamut.swift), which decides when a color counts as gray.
- [ColorRampGenerator.swift](https://github.com/metasidd/ColorTokensKit-Swift/blob/main/Sources/ColorTokensKit/Services/Ramps/ColorRampGenerator.swift), which wraps the hue and caches each ramp.
- [ColorTokens.swift](https://github.com/metasidd/ColorTokensKit-Swift/blob/main/Tests/ColorTokensKitTests/Marketing/Setup/ColorTokens.swift), which adds the 20 tokens.

## See also

- [Ready-made themes](https://colortokenskit.com/api/named-themes/index.md): 37 themes to use as they are, from proPink to proRose, each with 20 stops and every token.
- [Stops: .\_50 to .\_1000](https://colortokenskit.com/api/stops/index.md): Twenty fixed colors per theme, lightest to darkest, with the same contrast in every hue.
- [Replacing your app colors](https://colortokenskit.com/getting-started/replacing-colors/index.md): Move backgrounds, text, outlines, states, gradients and charts to tokens, one screen at a time.
- [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 the ramps are built](https://colortokenskit.com/under-the-hood/how-ramps-are-built/index.md): The lightness and chroma behind every stop, so any hue you pick gives the same contrast.

---

From ColorTokensKit, by Penguin Design Ventures: https://colortokenskit.com/api/protheme/ (updated September 26, 2026).
