# How colors land on exact stops

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

The live page lightens a palette color and a nearby off-palette color step by step. `Color.proBlue._600` lightens to exactly `proBlue._550` (#4882ca); #3C80C4, which sits between stops, lightens to #498cd1. [Try it on the live page](https://colortokenskit.com/under-the-hood/palette-snapping/).

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:)`](https://colortokenskit.com/api/lighten/index.md) 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\*](https://colortokenskit.com/reference/glossary/index.md#cielab-and-l) is within 0.6 of the stop's, and its [chroma](https://colortokenskit.com/reference/glossary/index.md#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](https://colortokenskit.com/under-the-hood/gamut-mapping/index.md) 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](https://github.com/metasidd/ColorTokensKit-Swift/blob/main/Sources/ColorTokensKit/Services/Ramps/PaletteStop.swift), `StopLadder.swift` and `ColorAdjustment.swift`.

## See also

- [Building for interaction states](https://colortokenskit.com/getting-started/interaction-states/index.md): Give buttons and rows hover, pressed, selected and disabled colors that stay readable in both modes.
- [How the ramps are built](https://colortokenskit.com/under-the-hood/how-ramps-are-built/index.md): The lightness and chroma behind every stop, so any hue you pick gives the same contrast.
- [What are tokens?](https://colortokenskit.com/basics/what-are-tokens/index.md): Colors picked one at a time never line up. Tokens name each job and pick its color in both modes.
- [.contrastRatio(to:method:)](https://colortokenskit.com/api/contrast-ratio/index.md): Check WCAG 2 and APCA contrast in Swift, and keep your color pairs passing with a unit test.

---

From ColorTokensKit, by Penguin Design Ventures: https://colortokenskit.com/under-the-hood/palette-snapping/ (updated September 26, 2026).
