Skip to content
ColorTokensKitby Penguin Design Ventures
Contents
Explanation

How gradients blend and ease

Why plain gradients turn gray, how each blend travels between colors, and where easing puts them.

Updated View as Markdown
Try it / Blends compared
Colors

.vivid (default)

Around the hue wheel, the short way

33 stops

.direct

A straight line through OKLab, like SwiftUI's own gradients

31 stops

.rainbow

Around the hue wheel, the long way

33 stops

RGB

SwiftUI's .device, and CSS's default: through gray

All rows use easing: .linear, so only the path differs.

Halfway from blue to yellow, the default blend, .vivid, gives a teal, .direct a pale blue and .rainbow a pinkish red. A plain RGB blend gives gray. Switch to "Palette stops" to try a pair of your own. blend: picks the path on any of the gradient functions:

swift
let blue = Color(hex: "#0000FF")
let yellow = Color(hex: "#FFFF00")

[blue, yellow].proGradient()                   // .vivid: through teal and green
[blue, yellow].proGradient(blend: .direct)     // a straight line, no green
[blue, yellow].proGradient(blend: .rainbow)    // through purple, red and orange

Use .vivid for brand gradients and harmonies, .direct when no other hues should appear, and .rainbow for rainbow sweeps. With three or more colors, the blend applies to each pair of neighbors in turn.

Why plain gradients turn gray#

An RGB blend mixes the red, green and blue channels on their own, so halfway from #0000FF to #FFFF00 each channel is at 50%, which is gray. SwiftUI's .device color space blends this way, and so do CSS gradients between hex colors by default. ColorTokensKit never blends in RGB.

.direct is a straight line through OKLab, which keeps more color, and in our measurements on iOS it's also the path SwiftUI's own gradients take, with or without .perceptual. But hues far apart on the wheel sit on opposite sides of the gray axis, so the line passes close to it. .vivid moves lightness, chroma and hue evenly instead, the path CSS takes for linear-gradient(in oklch, …). Where a path leaves Display P3, the library lowers chroma the way CSS Color 4 does, as fitting colors into Display P3 explains.

Gray and clear ends#

Gray, white and black have no hue, so a gray end borrows the other end's: with .vivid, proGray._300 to proBlue._600 stays at blue's hue all the way. A fully transparent end takes the other end's color, so [glow, .clear] only fades, instead of drifting toward whatever color .clear is stored as.

Use .vivid for these ends. With .rainbow, both ends share one hue, and the long way from a hue back to itself is a full circle, so the gradient sweeps through every hue.

Where easing puts the colors#

Try it / Easing

.smooth

The default: lingers a little at both ends

.linear

Changes evenly

.easeIn

Lingers on the first color

.easeOut

Lingers on the last color

.easeInOut

Lingers longer at both ends

.timingCurve(0.80, 0.00, 0.20, 1.00)

Your own curve

Each row draws violet _700 to pink _200 with one easing, next to its curve, where the dashed line is .linear. Drag the sliders to shape the last row's curve. Easing moves where each color falls, never which colors appear.

.smooth, the default, barely moves at the ends: across the first tenth of the gradient, it covers 2.4% of the change. Where a linear gradient meets a flat color, the rate of change jumps, and the eye exaggerates the jump into a faint line, an effect called Mach bands. An eased gradient's edges don't show.

The names come from SwiftUI's Animation, and .easeIn, .easeOut and .easeInOut use the control points CSS defines for ease-in, ease-out and ease-in-out. .smooth is the curve known as ease-in-out-sine, so a design tool can match it. It isn't SwiftUI's Animation.smooth, which is a spring. With more than two colors, the easing runs across the whole gradient, not between each pair.

.easeOut does most of its change early, so a glow drops off fast and then lingers:

swift
Circle()
    .fill(Color.proPink._400.proRadialGradient(.fade, easing: .easeOut))   // Kyoto's pink, fading out
    .frame(width: 80, height: 80)
light mode

Custom timing curves#

.timingCurve(_:_:_:_:) takes a cubic Bézier's two control points, as SwiftUI's Animation.timingCurve does, and holds them between 0 and 1, since a gradient can't overshoot its colors. Add yours as a static property, and it reads like a preset. This one puts most of the change in the middle:

swift
extension ProGradient.Easing {
    static let snap = ProGradient.Easing.timingCurve(0.8, 0, 0.2, 1)
}

let trip = [Color.proSky._400, Color.proPink._400]   // Reykjavík to Kyoto

Rectangle()
    .fill(trip.proGradient(from: .leading, to: .trailing, easing: .snap))
    .frame(height: 64)
light mode

Sources#

See also

  • Why OKLCH? Every hue looks evenly matched at each stop, so you can swap families without rechecking contrast.
  • Gradient theory Add depth with gradients that stay vivid, from a single color or a recipe, in both modes.
  • .proGradient() Fill cards and buttons with gradients that stay vivid end to end, in light and dark mode.
  • .blend(with:by:) Mix two colors into one that follows dark mode, or fade a color out cleanly.