Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 96 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Kotlin Multiplatform color picker library for Android, iOS, Desktop (JVM), and W
- Compose Multiplatform (Android, iOS, Desktop/JVM, Web/Wasm)
- Material 3 theming via `ColorPickerDefaults`
- HSL, RGB, CMYK, and LAB color models
- Perceptual color: Okhsl and Okhsv pickers, with Oklab and OkLCh for interchange and manipulation
- CSS Color 4 gamut mapping, so an out-of-gamut Oklab or OkLCh color keeps its lightness and hue
- Alpha channel support
- Zero-drift editing: `ColorPickerState` keeps the authoritative color in the space you edited, so edit-in-X-read-X is always exact (conversions themselves are float-based)
- Unidirectional data flow with `ColorPickerState`
Expand Down Expand Up @@ -43,12 +45,14 @@ Every picker takes a `ColoringMode`. `Independent` shows each channel's full ran
`Contextual` previews the resulting color at every slider position. `HslColorPicker`
defaults to `Independent`; the others default to `Contextual`.

| Model | Independent | Contextual |
|-------------------------------|-------------------------------------------------------|-----------------------------------------------------|
| **HSL**<br>`HslColorPicker` | ![HSL independent](docs/images/hsl-independent.png) | ![HSL contextual](docs/images/hsl-contextual.png) |
| **RGB**<br>`RgbColorPicker` | ![RGB independent](docs/images/rgb-independent.png) | ![RGB contextual](docs/images/rgb-contextual.png) |
| **CMYK**<br>`CmykColorPicker` | ![CMYK independent](docs/images/cmyk-independent.png) | ![CMYK contextual](docs/images/cmyk-contextual.png) |
| **LAB**<br>`LabColorPicker` | ![LAB independent](docs/images/lab-independent.png) | ![LAB contextual](docs/images/lab-contextual.png) |
| Model | Independent | Contextual |
|---------------------------------|---------------------------------------------------------|-------------------------------------------------------|
| **HSL**<br>`HslColorPicker` | ![HSL independent](docs/images/hsl-independent.png) | ![HSL contextual](docs/images/hsl-contextual.png) |
| **RGB**<br>`RgbColorPicker` | ![RGB independent](docs/images/rgb-independent.png) | ![RGB contextual](docs/images/rgb-contextual.png) |
| **CMYK**<br>`CmykColorPicker` | ![CMYK independent](docs/images/cmyk-independent.png) | ![CMYK contextual](docs/images/cmyk-contextual.png) |
| **LAB**<br>`LabColorPicker` | ![LAB independent](docs/images/lab-independent.png) | ![LAB contextual](docs/images/lab-contextual.png) |
| **Okhsl**<br>`OkhslColorPicker` | ![Okhsl independent](docs/images/okhsl-independent.png) | ![Okhsl contextual](docs/images/okhsl-contextual.png) |
| **Okhsv**<br>`OkhsvColorPicker` | ![Okhsv independent](docs/images/okhsv-independent.png) | ![Okhsv contextual](docs/images/okhsv-contextual.png) |

`ColorSwatch` draws the color over a transparency checkerboard, so alpha reads correctly:

Expand Down Expand Up @@ -129,6 +133,73 @@ val color = LabColor(l = 53.23f, a = 80.11f, b = 67.22f)
LabColor.fromInt(l = 53, a = 80, b = 67)
```

### Okhsl

```kotlin
val color = OkhslColor(hue = 29.2f, saturation = 1f, lightness = 0.57f)
// hue: [0, 360), saturation: [0, 1], lightness: [0, 1], alpha: [0, 1]

OkhslColor.fromInt(hue = 29, saturation = 100, lightness = 57)
```

Björn Ottosson's perceptual replacement for HSL, and the one to reach for if you are
choosing between the two. `lightness` is perceived lightness, so a blue and a yellow at
`0.5` look equally light; in HSL they differ by more than half the scale. `saturation` is
measured against the sRGB gamut, so `1` is as colorful as the display can go at that hue
and lightness — every coordinate is a real color and no part of a slider is dead travel.

### Okhsv

```kotlin
val color = OkhsvColor(hue = 29.2f, saturation = 1f, value = 1f)
// hue: [0, 360), saturation: [0, 1], value: [0, 1], alpha: [0, 1]

OkhsvColor.fromInt(hue = 29, saturation = 100, value = 100)
```

Okhsl's perceptual hue and gamut-relative saturation in the HSV arrangement artists
expect: full saturation at full value is the most vivid form of a hue, and pulling value
down darkens toward black. Prefer Okhsl when the middle of the lightness track should be a
mid tone.

### Oklab

```kotlin
val color = OklabColor(l = 0.63f, a = 0.22f, b = 0.13f)
// l: [0, 1], a: [-0.4, 0.4], b: [-0.4, 0.4], alpha: [0, 1]
```

The perceptual space the two above are built on, and the one to interpolate, compare or
blend in — equal numeric steps are close to equal perceived steps, and moving `l` does not
drag the perceived hue with it. Note `l` runs `0..1`, not CIELAB's `0..100`, and the `a`
and `b` bounds are the reference range CSS Color 4 gives `oklab()`.

Oklab is not a space to put sliders on: `a` and `b` are not bounded by the display gamut,
so most of their range is unreachable, exactly as with LAB. Use Okhsl or Okhsv for that.

### OkLCh

```kotlin
val color = OklchColor(l = 0.63f, chroma = 0.26f, hue = 29.2f)
// l: [0, 1], chroma: [0, 0.4], hue: [0, 360), alpha: [0, 1]
```

Oklab in cylindrical form, and the space CSS exposes as `oklch()` — use it to move values
in and out of stylesheets, or to change one of lightness, chroma and hue while holding the
others.

### Gamut mapping

Oklab and OkLCh can describe colors sRGB cannot show. Converting one to RGB does not clamp
each channel independently, which would shift lightness and hue as a side effect. It runs
the [CSS Color 4 algorithm](https://www.w3.org/TR/css-color-4/#gamut-mapping): binary
search down the chroma axis, comparing each candidate against its clipped form, and stop
once the two are within a just-noticeable difference. Lightness and hue survive, chroma
pays, and the result matches what a browser would render.

Okhsl and Okhsv never need this — their coordinates are normalized against the gamut, so
they are inside it by construction.

## 🔄 Conversions

Conversions are extension functions. They operate on floats end to end — nothing is quantized to integers until you explicitly ask for an ARGB `Int` or a hex string. Like any color space conversion, a cross-space round trip is not guaranteed to be bit-exact; the zero-drift guarantee comes from `ColorPickerState`'s origin tracking (see [Architecture](#architecture-zero-drift-color-conversions)).
Expand All @@ -140,12 +211,19 @@ val cmyk = rgb.toCmyk()
val lab = rgb.toLab()
val argb = rgb.toArgbInt()

// Perceptual spaces
val oklab = rgb.toOklab()
val oklch = rgb.toOklch()
val okhsl = rgb.toOkhsl()
val okhsv = rgb.toOkhsv()

// Compose interop, both ways
val composeColor: Color = hsl.toComposeColor()
val backToHsl: HslColor = composeColor.toHslColor()
val backToRgb: RgbColor = composeColor.toRgbColor()
val backToCmyk: CmykColor = composeColor.toCmykColor()
val backToLab: LabColor = composeColor.toLabColor()
val backToOkhsl: OkhslColor = composeColor.toOkhslColor()
```

### Hex strings
Expand Down Expand Up @@ -183,6 +261,8 @@ HslColorPicker(
RgbColorPicker(state = state, showAlpha = true)
CmykColorPicker(state = state, showAlpha = true)
LabColorPicker(state = state, showAlpha = true)
OkhslColorPicker(state = state, showAlpha = true)
OkhsvColorPicker(state = state, showAlpha = true)
```

`ColoringMode` controls the slider gradients: `Independent` shows each channel's full range regardless of the other channels, `Contextual` previews the actual resulting color at each position.
Expand Down Expand Up @@ -213,6 +293,16 @@ LightnessLabSlider(state = state)
LabASlider(state = state)
LabBSlider(state = state)

// Okhsl
OkhslHueSlider(state = state)
OkhslSaturationSlider(state = state)
OkhslLightnessSlider(state = state)

// Okhsv
OkhsvHueSlider(state = state)
OkhsvSaturationSlider(state = state)
OkhsvValueSlider(state = state)

// Alpha (works with any origin space)
AlphaSlider(state = state)
```
Expand Down
Loading