Skip to content
ColorTokensKitby Penguin Design Ventures
Contents
Explanation

How colors land on exact stops

Derived colors land on exact stops, so you know their contrast before you run the app.

Updated View as Markdown
Try it / Palette snapping

A palette color

Color.proBlue._600.toColor().lighten()#4882ca · position 10 · proBlue._550

A color near it, off the palette

Color(hex: "#3C80C4").lighten()#598bcc · position 9.26 · between stops

Move the slider. proBlue._600 jumps from stop to stop, and #3C80C4, a similar blue that isn't on the palette, moves by the same steps but stays between them.

A color function like lighten(by:) moves a palette stop by whole stops. A token is a stop in each mode, so a color you derive from one is a real stop of your theme too, with a contrast you know before you run the app:

swift
theme.foregroundTertiary.soften()                      // exactly _650 in light mode, _350 in dark
theme.foregroundTertiary.lighten(by: 3)                // exactly _550 and _150
theme.foregroundTertiary.rotateHue(by: .degrees(32))   // _700 and _300 of the ramp 32° around the wheel
theme.backgroundTertiary.invert()                      // the mirrored stops, _850 and _350

At the ends, the stops hold: _50 doesn't get any lighter.

Colors off the palette#

swift
let nearby = Color(hex: "#3C80C4")   // between _550 and _600

nearby.lighten()        // #498CD1
nearby.lighten(by: 2)   // #5599DF
nearby.darken()         // #2F74B7

Any other color moves along the same ladder, continuously, so one stop lighter is the same step for every color. It keeps its hue and as much chroma as the screen can show. A gray moves on gray's own ladder.

Recognizing a palette color#

A color counts as a stop when its CIELab L* is within 0.6 of the stop's, and its chroma within 0.006 of what that stop's ramp gives its hue. That's enough to cover rounding in hex values, and too tight to match two stops.

A color with a chroma of 0.005 or less has no visible hue, so it's matched against gray's stops instead: proGray._400.lighten() is exactly proGray._350.

Functions that snap#

lighten, darken, soften, strengthen, tints, shades, invert and monochromatic land a palette color on stops, and rotateHue and the harmonies keep its stop at the new hue. saturate, desaturate and blend leave the palette on purpose. saturate and desaturate still keep L*, and with it contrast, as fitting colors into Display P3 explains.

The darkest gray#

Gray _1000 isn't recognized. Its hex, #000001, has a chroma just over the cutoff for gray, so proGray._1000.lighten() gives #0e1112 rather than gray _950, and so does lightening Color.white.invert(). Use Color.proGray._950 directly.

Sources#

  • ColorTokensKit source, PaletteStop.swift, StopLadder.swift and ColorAdjustment.swift.

See also