.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:
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 orangeUse .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#
.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:
Circle()
.fill(Color.proPink._400.proRadialGradient(.fade, easing: .easeOut)) // Kyoto's pink, fading out
.frame(width: 80, height: 80)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:
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)Sources#
- W3C, CSS Color Module Level 4, color interpolation, which defines the hue paths
in oklchtakes, and gamut mapping, which sets one just-noticeable difference in OKLCH at 0.02. - W3C, CSS Easing Functions Level 1, which defines
ease-in,ease-outandease-in-out. - Björn Ottosson, A perceptual color space for image processing, which defines OKLab and OKLCH.
- Apple,
Gradient.ColorSpace, which defines.deviceand.perceptual. - The library's GradientStops.swift, which builds every blend, and ProGradient.swift, which defines the easings.
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.