# How gradients blend and ease

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

The live page draws #0000FF to #FFFF00 with each blend. Halfway, in Display P3 hex, `.vivid` is #00c0b4, `.direct` is #7aaac4, `.rainbow` is #ff4c86, and a plain RGB blend is #808080. [Try it on the live page](https://colortokenskit.com/under-the-hood/gradient-blends-and-easing/).

Halfway from blue to yellow, the default [blend](https://colortokenskit.com/reference/glossary/index.md#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](https://colortokenskit.com/advanced/gradient-theory/index.md):

```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](https://colortokenskit.com/reference/glossary/index.md#oklab-and-oklch), 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](https://colortokenskit.com/under-the-hood/gamut-mapping/index.md) 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

The live page draws each easing's curve next to the gradient it makes, from `Color.proViolet._700` to `Color.proPink._200`, and lets you shape your own `.timingCurve`. [Try it on the live page](https://colortokenskit.com/under-the-hood/gradient-blends-and-easing/).

Each row draws violet `_700` to pink `_200` with one [easing](https://colortokenskit.com/reference/glossary/index.md#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)
```

What this draws:

| Color       | light mode | dark mode |
| ----------- | ---------- | --------- |
| `pink._400` | #fa7ba7    | #fa7ba7   |

It draws a radial gradient from the `.fade` recipe from `pink._400`, with `.vivid` and `.easeOut`.

## 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)
```

What this draws:

| Color       | light mode                            | dark mode                             |
| ----------- | ------------------------------------- | ------------------------------------- |
| `sky._400`  | color(display-p3 0.2313 0.6861 0.926) | color(display-p3 0.2313 0.6861 0.926) |
| `pink._400` | #fa7ba7                               | #fa7ba7                               |

It draws a linear gradient from `sky._400` to `pink._400`, with `.vivid` and `.timingCurve(0.8, 0, 0.2, 1)`.

## Sources

- W3C, [CSS Color Module Level 4, color interpolation](https://www.w3.org/TR/css-color-4/#interpolation), which defines the hue paths `in oklch` takes, and [gamut mapping](https://www.w3.org/TR/css-color-4/#gamut-mapping), which sets one just-noticeable difference in OKLCH at 0.02.
- W3C, [CSS Easing Functions Level 1](https://www.w3.org/TR/css-easing-1/), which defines `ease-in`, `ease-out` and `ease-in-out`.
- Björn Ottosson, [A perceptual color space for image processing](https://bottosson.github.io/posts/oklab/), which defines OKLab and OKLCH.
- Apple, [`Gradient.ColorSpace`](https://developer.apple.com/documentation/swiftui/gradient/colorspace), which defines `.device` and `.perceptual`.
- The library's [GradientStops.swift](https://github.com/metasidd/ColorTokensKit-Swift/blob/main/Sources/ColorTokensKit/Gradients/GradientStops.swift), which builds every blend, and [ProGradient.swift](https://github.com/metasidd/ColorTokensKit-Swift/blob/main/Sources/ColorTokensKit/Gradients/ProGradient.swift), which defines the easings.

## See also

- [Why OKLCH?](https://colortokenskit.com/under-the-hood/why-oklch/index.md): Every hue looks evenly matched at each stop, so you can swap families without rechecking contrast.
- [Gradient theory](https://colortokenskit.com/advanced/gradient-theory/index.md): Add depth with gradients that stay vivid, from a single color or a recipe, in both modes.
- [.proGradient()](https://colortokenskit.com/api/pro-gradient/index.md): Fill cards and buttons with gradients that stay vivid end to end, in light and dark mode.
- [.blend(with:by:)](https://colortokenskit.com/api/blend/index.md): Mix two colors into one that follows dark mode, or fade a color out cleanly.

---

From ColorTokensKit, by Penguin Design Ventures: https://colortokenskit.com/under-the-hood/gradient-blends-and-easing/ (updated September 26, 2026).
