Skip to content
ColorTokensKitby Penguin Design Ventures
Contents
Function

ProTheme

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

Updated View as Markdown

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"))
Color.proOrange · light mode
Lisbon3 nights from October 12
#00B386 · light mode
Lisbon3 nights from October 12

Where a theme comes from#

You haveUse
A hue with a nameColor.proOrange and the other ready-made themes
Your brand's hexProTheme(hex:)
A hue numberProTheme.primary(forHue:)
A color's OKLCH numbersProTheme(oklch:)
Another themeA harmony such as .complement, or .rotateHue(by:)

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 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 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
light mode
Book now

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

Try it / A theme from your hex

hue 167°, between proJade (160°) and proEmerald (170°) · as light as a stop between _400 and _450

50
100
150
200
250
300
350
400
450
500
550
600
650
700
750
800
850
900
950
1000

ProTheme(hex: "#00B386") · outlined: the stops your hex sits between · toColor() is still your hex

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: #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, 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)
}
ParameterTypeWhat it takes
hexString#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)
light mode
Lisbon

Drag the hue to build the ramp for any number:

Try it / A ramp for any hue

Between proJade (160°) and proEmerald (170°).

50
100
150
200
250
300
350
400
450
500
550
600
650
700
750
800
850
900
950
1000
Stop501001502002503003504004505005506006507007508008509009501000
L*96.592.788.884.880.676.37267.562.858.253.548.84439.234.429.524.619.91510.2
Chroma.017.037.058.081.107.134.163.161.16.15.141.131.121.112.102.092.082.072.062.053
vs white1.11.21.31.51.71.92.22.52.93.444.75.66.789.511.313.215.217.1

L* is the same for every hue. Dotted chroma: Display P3 can't show this hue at the shared chroma, so it gets as close as it can.

swift
extension Color {
    static var brand: ProTheme { .primary(forHue: 167) }
}

Text("Hello").foregroundStyle(Color.brand.foregroundPrimary)

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 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:), 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? 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]
}
ParameterTypeDefaultWhat it takes
hueDoublenoneAn OKLCH hue in degrees; numbers outside 0 to 360 wrap around
isGrayscaleBoolfalsetrue 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
light mode
Book now

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() 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:) 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)
ParameterTypeWhat it takes
oklchOKLCHColorAny 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 builds a picker from the ready-made themes.

What else a theme does#

The color functions, such as .darken(by:), 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 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#

See also

  • Ready-made themes 37 themes to use as they are, from proPink to proRose, each with 20 stops and every token.
  • Stops: ._50 to ._1000 Twenty fixed colors per theme, lightest to darkest, with the same contrast in every hue.
  • Replacing your app colors Move backgrounds, text, outlines, states, gradients and charts to tokens, one screen at a time.
  • Setting up themes Recolor a view, a screen or your whole app from one value, with the same contrast in every hue.
  • How the ramps are built The lightness and chroma behind every stop, so any hue you pick gives the same contrast.