Kotlin Multiplatform color picker library for Android, iOS, Desktop (JVM), and Web (Wasm), built with Compose Multiplatform: ready-made Material 3 pickers, and the same pickers without Material for a design system of your own.
This README describes 2.0, which is not released yet. The latest release is 1.2.1, documented at the v1.2.1 tag. Migrating from 1.x maps one API onto the other.
- Compose Multiplatform (Android, iOS, Desktop/JVM, Web/Wasm)
- A picker for any color space: the fifteen built in β sRGB, linear sRGB, Display P3, XYZ D65 and D50, Lab, LCH, Oklab, OkLCh, HSL, HWB, HSV, Okhsl, Okhsv and CMYK β and any an app defines
- Eleven ready-made pickers, each over a
ColorPickerState, aColorValueor a ComposeColor - A color model built on CSS Color 4: a
ColorValuekeeps the space it was written in, itsnonecomponents and any value outside sRGB - CSS color strings and hex, both ways
- CSS Color 4 gamut mapping, so a color outside sRGB is drawn with its lightness and hue intact
- A grey keeps its hue: dragged to grey and back, a color returns in the hue it had rather than red
- Two-dimensional planes over any two channels of a space
- Alpha channel support
- A color dialog showing the original color beside the new one, with a switcher between spaces
- Material 3 theming via
ColorPickerDefaultsandColorPickerTheme - The same components without Material, as
Basic*components that draw what their slots draw, for any other design system - Accessibility semantics throughout β sliders step by each channel's own unit, and planes take focus, move with the arrow keys, and offer a screen reader one named action per direction
- Words and numbers from one
ColorPickerStrings, with numbers in the device's format β40 %in French,Ω€Ω Ωͺin Egyptian Arabic; reword or translate by providing your own - RTL layout support everywhere but the planes, which map a color space rather than showing progress
// build.gradle.kts
implementation("codes.side:colorpicker-material3:2.0.0")In a Kotlin Multiplatform project, add it to commonMain:
kotlin {
sourceSets {
commonMain.dependencies {
implementation("codes.side:colorpicker-material3:2.0.0")
}
}
}On a design system other than Material, depend on codes.side:colorpicker-foundation instead and draw
the pickers yourself: see Without Material.
Each artifact brings the ones below it, and each works on its own:
| Artifact | Holds |
|---|---|
codes.side:colorpicker-material3 |
The Material 3 pickers, sliders, planes, swatch, dialog and theme, in codes.side.colorpicker.material3. |
codes.side:colorpicker-foundation |
ColorPickerState, in codes.side.colorpicker.state; the Basic* components and ColorPickerStrings, in codes.side.colorpicker.foundation. No Material. |
codes.side:color-compose |
ColorValue.toComposeColor() and Color.toColorValue(). |
codes.side:color |
ColorValue, the color spaces, conversion, gamut mapping, CSS strings and hex. No Compose. |
Published targets: android, jvm, iosArm64, iosSimulatorArm64, wasmJs.
Every picker takes a ColoringMode. Independent shows each channel's full range;
Contextual previews the resulting color at every slider position. A picker over a space
with a hue defaults to Independent; the RGB, Lab, Oklab and CMYK pickers default to
Contextual.
ColorSwatch draws the color over a transparency checkerboard, so alpha reads correctly:
These images are the Compose Preview Screenshot Testing references, rendered from the
library's own components and re-checked on every CI run, so they cannot drift from what
the code actually draws. Regenerate them with
./gradlew :screenshot-tests:updateDebugScreenshotTest.
@Composable
fun MyScreen() {
val state = rememberColorPickerState(Okhsl(250.0, 0.8, 0.6))
Column {
ColorPicker(state)
ColorSwatch(color = state.color, modifier = Modifier.size(48.dp))
}
}ColorPicker is an Okhsl picker unless given a space: its lightness is perceived lightness and
its saturation is measured against the display, so every position is a color the screen shows.
ColorPicker(state, space = OkLch) picks in any other space, and the named pickers,
HslColorPicker to CmykColorPicker, fix one.
A picker can equally sit over a value you hold yourself:
var color by remember { mutableStateOf(Color(0xFF3366CC)) }
HslColorPicker(color = color, onColorChange = { color = it })A ColorValue is a color in one space: that space's components, in its own units, and an alpha.
Each space builds one:
val teal = Okhsl(200.0, 0.8, 0.5) // hue, saturation, lightness
val halfRed = Srgb(1.0, 0.0, 0.0, 0.5) // alpha comes last
val vivid = OkLch(0.7, 0.3, 150.0) // more chroma than sRGB can show
teal.space // Okhsl
teal[Okhsl.L] // 0.5
teal.to(OkLch) // the same color, in OkLCh
teal.with(Okhsl.L, 0.7) // lighter, and still Okhsl
teal.withAlpha(0.5)A value stays in its space until you convert it, so an OkLCh chroma of 0.3 or a Display P3 red
is kept as it is, and only drawing it maps it into what the screen can show. A component can be
null, CSS's none: a grey's hue is missing rather than 0, because it has none.
The units are CSS's, so a number copied from a stylesheet or a design tool means the same here:
| Space | Channels | Units |
|---|---|---|
Srgb, SrgbLinear, DisplayP3 |
R, G, B |
0β1, and past it for a color outside the gamut |
XyzD65, XyzD50 |
X, Y, Z |
the white's Y is 1 |
Lab |
L, A, B |
L 0β100; a and b about Β±125 |
Lch |
L, C, H |
L 0β100, C 0β150, H 0β360 |
Oklab |
L, A, B |
L 0β1; a and b about Β±0.4 |
OkLch |
L, C, H |
L 0β1, C 0β0.4, H 0β360 |
Hsl, Hsv, Hwb |
H, S, L; H, S, V; H, W, B |
H 0β360, the rest 0β100 |
Okhsl, Okhsv |
H, S, L; H, S, V |
H 0β360, the rest 0β1 |
Cmyk |
C, M, Y, K |
0β1 |
- Okhsl is 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.5look equally light; in HSL they differ by more than half the scale. Saturation is measured against the sRGB gamut, so1is 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 has 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 is the perceptual space the two above are built on, and the one to interpolate,
compare or blend in: equal steps are close to equal perceived steps, and moving
Ldoes not drag the perceived hue with it. OkLCh is its cylindrical form, CSS'soklch(), for changing one of lightness, chroma and hue while holding the others. Neither is bounded by the display, so most of their range lies outside sRGB and is drawn as the nearest color sRGB holds. - Lab and LCH are CIELAB with a D50 white, which is what CSS
lab(), Photoshop and Compose'sColorSpaces.CieLaball quote, so a value copied from any of them means here what it meant there. About an eighth of the aβb square is inside sRGB. - HSL, HSV and HWB are CSS's formulas over sRGB.
- CMYK is the naive conversion, with no color profile. It round-trips on screen and is not what a press will print β real CMYK is device dependent, its gamut is not sRGB's, and crossing between them needs an ICC profile and a rendering intent. Treat it as a screen-space parameterisation rather than ink.
- sRGB, linear sRGB, Display P3 and XYZ are there for interchange, and
ColorPicker(state, space = DisplayP3)puts sliders on any of them.
val brandHsl = ColorSpace.hsl("--brand-hsl", over = DisplayP3)
ColorPicker(state, space = brandHsl) // a plane and H, S and L sliders, over Display P3ColorSpace.rgb, hsl, hsv, hwb and polar build one. Its id is a CSS custom name, so
toCssString writes it as color(--brand-hsl β¦), and parseCss reads that back when the space is
among those it is given.
Drawing a color outside sRGB does not clamp each channel independently, which would shift lightness and hue as a side effect. It runs the CSS Color 4 algorithm: 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 and chroma pays.
val vivid = OkLch(0.7, 0.3, 150.0)
vivid.isInGamut(Srgb.gamut) // false
vivid.toGamut(Srgb.gamut) // the same lightness and hue, less chroma
vivid.toGamut(Srgb.gamut, GamutMapping.Clip) // each channel clipped, when that is what you want
vivid.toComposeColor() // mapped as toGamut maps itThe search runs in Oklab whatever space the color came from, as CSS specifies, so what survives is Oklab's lightness and hue, not CIELAB's. Okhsl and Okhsv never need it: their saturation is measured against the gamut, so they are inside it by construction.
OkLch(0.7, 0.15, 140.0).toCssString() // "oklch(0.7 0.15 140)"
Hsl(120.0, 50.0, 25.0).toCssString() // "hsl(120 50% 25%)"
Okhsl(120.0, 0.5, 0.25).toCssString() // "color(--okhsl 120 0.5 0.25)"
Hsl(120.0, 50.0, null).toCssString() // "hsl(120 50% none)"
ColorValue.parseCss("oklch(70% 0.15 140 / 50%)") // an OkLCh value at half alpha
ColorValue.parseCssOrNull("not a color") // null, never throwsA value is written in its own space and never mapped into a gamut, so a Display P3 red stays
color(display-p3 1 0 0). The library's HSV, Okhsl, Okhsv and CMYK and an app's spaces are
written as color(--name β¦), which parseCss reads back.
Hex is always sRGB, eight bits a channel, mapped into the gamut first:
val blue = Srgb(0.2, 0.5, 0.8)
blue.toHexString(HexAlpha.First) // "#FF3380CC", as android.graphics.Color writes it
blue.toHexString(HexAlpha.Last) // "#3380CCFF", as CSS writes it
blue.toHexString(HexAlpha.None) // "#3380CC"
ColorValue.parseHex("#3380CC", HexAlpha.None) // throws on invalid input
ColorValue.parseHexOrNull("#ABC", HexAlpha.None) // shorthand, expands to #AABBCCFour and eight hex digits carry an alpha channel and cannot tell you at which end β
#FF000080 is a half-transparent red to a stylesheet and an opaque navy to
android.graphics.Color. Neither end has a default, so a round trip names the same ordering
twice and the pair reads off one screen:
val stored = blue.toHexString(HexAlpha.First)
ColorValue.parseHexOrNull(stored, HexAlpha.First) // the color that went in
ColorValue.parseHexOrNull("#FF000080", HexAlpha.First) // opaque navy, as Android reads it
ColorValue.parseHexOrNull("#FF000080", HexAlpha.Last) // half-transparent red, as CSS reads it
ColorValue.parseHexOrNull("#FF000080", HexAlpha.None) // null: the opaque forms onlyThree and six digits carry no alpha, so they mean the same thing whichever you name.
val color: Color = teal.toComposeColor() // mapped into sRGB, as toGamut maps it
val value: ColorValue = color.toColorValue() // sRGB, or the Compose color space it is inA Compose Color in Display P3 or another of Compose's RGB spaces arrives in that space, not
clipped into sRGB.
val state = rememberColorPickerState(Okhsl(250.0, 0.8, 0.6))
ColorPicker(state) // Okhsl: a plane, three sliders and alpha
ColorPicker(state, space = OkLch) // any space, the library's or an app's
OkLchColorPicker(state) // the same, by name
ColorPicker(
state = state,
space = Cmyk,
alphaSlider = null,
coloringMode = ColoringMode.Independent,
)A picker draws a plane when its space has one hue and two other channels, then a slider for each channel in the space's order, then alpha. The plane runs across the channel that measures colorfulness and up the other: HSL's S Γ L, HSV's S Γ V, HWB's W Γ B, LCH's and OkLCh's C Γ L, Okhsl's S Γ L and Okhsv's S Γ V. Moving any part leaves the color in the picker's space.
plane = null and alphaSlider = null leave those parts out, and orientation = Orientation.Horizontal
puts the plane beside the sliders, in the start half:
The eleven named pickers are RgbColorPicker (sRGB), HslColorPicker, HsvColorPicker,
HwbColorPicker, LabColorPicker, LchColorPicker, OklabColorPicker, OkLchColorPicker,
OkhslColorPicker, OkhsvColorPicker and CmykColorPicker. Each is ColorPicker with its
space fixed and takes the same parameters.
Every part of a picker is a slot, handed the state, and the channel for a slider or the two axes for the plane. Replace one to change that part alone β here, the label of one slider:
HslColorPicker(
state = state,
channelSlider = { state, channel ->
if (channel === Hsl.H) {
val hue = stringResource(Res.string.hue)
ChannelSlider(state, channel, label = { SliderLabel(hue) }, semanticLabel = hue)
} else {
ChannelSlider(state, channel)
}
},
)A replacement inherits the picker's colors, shapes and dimensions through the theme, so only
what you actually want to change has to be named. enabled reaches it the same way: in a
disabled picker, the library's sliders and planes are disabled whether or not they were told,
and any other control in a slot is refused the pointer:
HslColorPicker(state = state, enabled = false) // dimmed, inert, and disabled to a screen readerthumb reaches every slider in the picker, so the custom thumb below works
here too rather than only on a slider built by hand. onValueChangeFinished is called when a
tap or drag ends, and after each key press or screen reader step, whichever part moved.
Every picker also comes in two fully controlled forms, as Compose's Slider(value, onValueChange)
is: over a ColorValue, and over a Compose Color.
var value by remember { mutableStateOf<ColorValue>(Okhsl(250.0, 0.8, 0.6)) }
ColorPicker(value = value, onValueChange = { value = it })
var color by remember { mutableStateOf(Color(0xFF3366CC)) }
OkhslColorPicker(color = color, onColorChange = { color = it })- Every change reaches the callback in the same event, as the whole new value in the picker's space, and the picker draws only what you pass back. Ignore the callback and the picker holds still; clamp or round the value and it shows the clamp or the rounding at once.
- A change of alpha alone comes back in your value's own space, so opacity never pulls a Display P3 color into an Okhsl picker's sRGB.
- A value you pass in is drawn, and never reported back to you.
- The
Colorform keeps the exact value behind the last color it reported, so an Okhsl picker never steps through 8-bit sRGB, and an edit outside sRGB stays where the user put it. Hold aColorValueto keep wide gamut andnoneon your side too. - A value that arrives late β debounced, from a store, through a coroutine β is drawn when it
arrives, while a drag carries on from the finger. A caller like that is better served by
holding a
ColorPickerStateand observing it.
ChannelSlider(state, channel) is a slider for one channel of any space, and AlphaSlider(state)
one for alpha. Compose any set of them against a shared state:
ChannelSlider(state, Okhsl.H)
ChannelSlider(state, OkLch.C) // 0 to 0.4, CSS's reference range
ChannelSlider(state, OkLch.C, range = 0.0..0.2) // a narrower track
ChannelSlider(state, Srgb.R)
AlphaSlider(state)- The thumb sits at the channel's displayed value, so a grey's hue slider shows the hue last
chosen. A value outside
range, such as OkLCh chroma 0.5 or extended sRGB, pins the thumb to that end while the label keeps its true number; it changes only when the user moves the slider. Contextualdraws each point of the track as the color the slider would make there;Independentholds the other channels at clear colors of middle lightness. Either way the track is computed in the channel's space and brought into sRGB by chroma reduction.- Left and Right move by the channel's
stepand Page Up and Page Down by itspageStep: a degree and ten on a hue, 1/255 and 17/255 on an RGB channel, 0.001 and 0.01 on OkLCh chroma. Home and End jump to the ends ofrange, and Up and Down are left for moving focus. A screen reader steps bysteptoo. AlphaSlideredits alpha alone and keeps the color's space. A missing alpha reads 0, as CSS readsnone.
Sliders expose slots and semantics for customization:
ChannelSlider(
state = state,
channel = Hsl.H,
label = { SliderLabel("Farbton") }, // leading label slot (null to hide)
valueLabel = { SliderValueLabel("200Β°") }, // trailing value slot (null to hide)
semanticLabel = "Farbton", // accessibility label
semanticValueText = "200 Grad", // accessibility value announcement
)ChannelPlane(state, x, y) picks two channels of one space at once, holding the space's others,
so with a ChannelSlider for the rest and an AlphaSlider it makes a full picker.
| Plane | Preview |
|---|---|
HSLChannelPlane(state, Hsl.S, Hsl.L) |
![]() |
OkhslChannelPlane(state, Okhsl.S, Okhsl.L) |
![]() |
OkhsvChannelPlane(state, Okhsv.S, Okhsv.V) |
![]() |
val state = rememberColorPickerState(ColorValue.parseCss("#9BB7D4"))
ChannelPlane(state, Hsl.S, Hsl.L, Modifier.fillMaxWidth().height(220.dp))
ChannelSlider(state, Hsl.H)x runs left to right and y bottom to top, each over its channel's reference range.
HSL's S Γ L and HSV's S Γ V are drawn exactly, with two gradients. HSL's is a horizontal
grey-to-hue ramp under a white / transparent / black overlay: the color at lightness L is the
mid-lightness color blended toward white by 2L-1 above the middle and toward black by 1-2L
below it, which is what compositing the overlay computes. Every other pair has no such identity,
so it is sampled on a grid, measured for each of the library's planes, and drawn scaled. The grid
is rebuilt off the main thread when a held channel changes.
LocalPlaneRendering decides how. PlaneRendering.Fast, the default, shares the rows among up to
four threads, builds Okhsl's S Γ L as two smaller grids that meet on its crease, and draws each
sample where its grid is measured. PlaneRendering.Canonical is the reference it is tested against.
The surface is not mirrored in right-to-left layouts, unlike the sliders. It maps a color space rather than showing progress.
A plane is reachable without a pointer. It takes focus β by tab or by being pressed β and the
arrow keys move each channel by its step, or its pageStep with Shift held. An arrow it cannot
use, because that edge is already reached, is passed on, so focus can still leave on a device
driven by a D-pad alone. The focus ring is drawn only while the input mode is keyboard, so a
finger that took focus by pressing the surface does not leave one behind. A screen reader has no
gesture for two degrees of freedom, so each direction is offered as a named action instead,
stepping by pageStep because an action menu has no modifier key to hold:
ChannelPlane(
state = state,
x = Hsl.S,
y = Hsl.L,
actionLabels = PlaneActionLabels( // read aloud, so localize them
increaseX = "SΓ€ttigung erhΓΆhen",
decreaseX = "SΓ€ttigung verringern",
increaseY = "Helligkeit erhΓΆhen",
decreaseY = "Helligkeit verringern",
),
)Passing null drops the actions and leaves the plane readable but not adjustable.
thumb replaces the position indicator. It reads the plane's InteractionSource from its scope β
which carries focus as well as drag, so a replacement can mark keyboard focus the way the default
indicator does, with a second ring β and, on a ChannelPlane, the channels and the color under it:
ChannelPlane(state, Hsl.S, Hsl.L, thumb = { MyIndicator(interactionSource, thumbColor) })Every slider takes a thumb slot, so the Material 3 thumb can be replaced outright β its
size, shape and stroke are yours rather than a fixed set of dimension parameters. The slot
reads the slider's InteractionSource, position and thumb color from its scope, so a thumb can
also react to press and drag.
private val SquareThumbSize = 48.dp // M3 minimum interactive size
@Composable
fun SquareThumb(color: Color, interaction: InteractionSource) {
val fill = color.copy(alpha = 1f)
val shape = RoundedCornerShape(16.dp)
val ring = lerp(fill, Color.White, 0.6f)
val dragged by interaction.collectIsDraggedAsState()
val pressed by interaction.collectIsPressedAsState()
val elevation by animateDpAsState(if (dragged || pressed) 4.dp else 0.dp)
Box(
Modifier
.size(SquareThumbSize)
.shadow(elevation, shape)
.background(ring, shape)
.padding(5.dp)
.background(fill, RoundedCornerShape(11.dp))
)
}
ChannelSlider(
state = state,
channel = Okhsl.H,
thumb = { SquareThumb(state.color, interactionSource) },
// so the track leaves room for it
dimensions = ColorPickerDefaults.currentDimensions().copy(thumbWidth = SquareThumbSize),
)The thumb needs no state parameter: state is already in scope at the call site, so the
composable restyles itself as the color changes. The ring is the fill lifted toward white
rather than a light-or-dark choice made at some luminance threshold β a threshold snaps
visibly the moment a drag crosses it, while this moves with the color. And because the
slot reads the slider's InteractionSource, the thumb can react to being dragged;
that is state a caller cannot otherwise reach, since the source is created inside the
slider.
thumbWidth matters. The track breaks around the thumb, and it sizes that break from this
value, ColorPickerDefaults.ThumbWidth (the Material 3 handle) by default. A wider thumb
that does not declare its width covers the gap and sits flush against the gradient.
thumbTrackGap controls the clearance itself. Copying currentDimensions() changes the one
size and keeps whatever an enclosing ColorPickerTheme set for the rest. The sample app's
Custom thumb section runs exactly this code.
Renders a color over a transparency checkerboard:
ColorSwatch(
color = state.color,
modifier = Modifier.fillMaxWidth().height(48.dp),
contentDescription = "Selected color",
)A Material 3 AlertDialog: the original color beside the one being edited, with its hex, a
switcher between Okhsl, OkLCh, HSV and RGB, and the picker for the space shown. The value and the
callbacks come first; everything else has defaults:
ColorPickerDialog(
initialValue = state.value,
onValueSelected = { value -> state.value = value /* and close */ },
onDismissRequest = { /* close */ },
)Switching space redraws the color without converting it. Confirming returns exactly the initial
value if nothing was edited, or if the original half of the swatch was pressed to restore it, so an
untouched Display P3 color is not clipped into sRGB on the way out. An edit of alpha alone keeps the
value's own space; any other edit returns the value in the space it was made in, and passing that
value back opens the dialog in that space again.
ColorPickerDialog(initialColor, onColorSelected, onDismissRequest) does the same over a Compose
Color, opening in the first space.
spaces sets what the switcher offers: one space leaves it out, up to five are segmented buttons,
and more are a menu. The body stacks when that fits the dialog's height, and otherwise, where the
dialog is wide enough, puts the plane beside the sliders. A phone's dialog is not that wide even in
landscape, so there the body scrolls.
In-progress edits and the space shown survive configuration changes; passing a new initial value resets the dialog.
Every part is a slot. The title, header, switcher and buttons are handed a ColorPickerDialogScope,
which reads the dialog's state and confirms or dismisses it:
ColorPickerDialog(
initialValue = state.value,
onValueSelected = { /* ... */ },
onDismissRequest = { /* ... */ },
confirmButton = {
TextButton(onClick = { confirm() }, enabled = state.isModified) { Text("Apply") }
},
)The picker's slots are handed the dialog's own ColorPickerState β without it a replacement would
have nothing to read or write:
ColorPickerDialog(
initialValue = state.value,
onValueSelected = { /* ... */ },
onDismissRequest = { /* ... */ },
channelSlider = { state, channel ->
if (channel === Okhsl.H) {
val hue = stringResource(Res.string.hue)
ChannelSlider(state, channel, label = { SliderLabel(hue) }, semanticLabel = hue)
} else {
ChannelSlider(state, channel)
}
},
)Every label, spoken description and value the components show comes from ColorPickerStrings:
English words, with numbers in the device's format β 40%, 40 % in French, %40 in Turkish,
Ω€Ω Ωͺ in Egyptian Arabic β and every slider and plane announces its value to a screen reader in
the channel's own units. To reword or translate, implement the members you need and provide the
result once, around a screen or the whole app:
object GermanPickerStrings : ColorPickerStrings {
@Composable
override fun channelName(channel: ColorChannel): String =
if (channel === Hsl.H) stringResource(Res.string.hue) else super.channelName(channel)
@Composable
override fun dialogTitle(): String = stringResource(Res.string.select_color)
}
ProvideColorPickerStrings(GermanPickerStrings) {
HslColorPicker(state = state)
}Members are composable, so an implementation reads your own resources. Every member has a body in the library's words, so a member you do not override β or one added in a later version β keeps them. A string passed to a component still wins over the provided ones.
Every slider, plane and picker accepts colors, shapes and dimensions built with
ColorPickerDefaults, and ColorSwatch its colors and a shape; the colors and shapes derive
from MaterialTheme by default:
ColorPicker(
state = state,
colors = ColorPickerDefaults.colors(
checkerboardLight = Color.White,
checkerboardDark = Color.LightGray,
),
shapes = ColorPickerDefaults.shapes(
trackShape = RoundedCornerShape(4.dp),
swatchShape = RoundedCornerShape(8.dp),
),
)ColorPickerTheme sets them for everything inside it instead, which is how a track height
reaches every slider without being a parameter on any of them:
ColorPickerTheme(
dimensions = ColorPickerDefaults.dimensions(trackHeight = 24.dp),
) {
ColorPicker(state)
ChannelPlane(state, Okhsl.S, Okhsl.L)
}A component reads the theme in its parameter defaults, so an explicit argument still wins over
whatever an enclosing ColorPickerTheme provided.
ColorPickerDefaults.colors(), shapes() and dimensions() keep every value you leave out, and
each class's copy does the same from one you already have, such as
ColorPickerDefaults.currentColors().
codes.side:colorpicker-foundation holds the same components with no look of their own. Each draws
what its slots draw, and a slot reads what it needs from a scope: a slider's position, its color and
whether it is enabled, a plane's position, a channel slider's gradient. Dragging, the keyboard, a
screen reader and the picker's disabled state work as they do in the Material components, which are
built on these.
| Material 3 | Foundation |
|---|---|
ColorPicker |
BasicColorPicker |
ChannelSlider |
BasicChannelSlider |
AlphaSlider |
BasicAlphaSlider |
ColorSlider |
BasicColorSlider |
ChannelPlane |
BasicChannelPlane |
ColorPlane |
BasicColorPlane |
ColorSwatch |
a clip, Modifier.checkerboard and a background |
ColorComparison |
BasicColorComparison |
ColorPickerDialog |
BasicColorPickerDialogContent over a ColorPickerDialogState, in a window of your own |
A picker with round thumbs and pill tracks, and nothing from Material:
BasicColorPicker(
state = state,
space = Okhsv,
plane = { s, x, y ->
BasicChannelPlane(
s, x, y,
Modifier.fillMaxWidth().aspectRatio(1.6f),
shape = RoundedCornerShape(16.dp),
thumb = { RoundThumb(thumbColor) },
)
},
channelSlider = { s, channel ->
BasicChannelSlider(
s, channel, Modifier.fillMaxWidth(),
track = { Box(Modifier.fillMaxWidth().height(12.dp).clip(CircleShape).background(gradient)) },
thumb = { RoundThumb(thumbColor) },
)
},
alphaSlider = { s ->
BasicAlphaSlider(
s, Modifier.fillMaxWidth(),
track = {
Box(
Modifier.fillMaxWidth().height(12.dp).clip(CircleShape)
.checkerboard(Color.White, Color.LightGray)
.background(gradient),
)
},
thumb = { RoundThumb(thumbColor) },
)
},
spacing = 16.dp,
)
@Composable
fun RoundThumb(color: Color) {
Box(Modifier.size(24.dp).background(Color.White, CircleShape).padding(3.dp).background(color, CircleShape))
}A slot reads from its scope whether it is enabled. What lies outside every scope reads
LocalColorPickerEnabled to look disabled with the picker: a label drawn above a slider, or the field
a BasicChannelPlane paints, which it leaves undimmed when disabled. The sample app's Built on
foundation section runs a picker like this one, its plane dimmed through its modifier.
ColorPickerState holds the color a picker edits, as one ColorValue, and the hues it
remembers beside it.
val state = rememberColorPickerState(Okhsl(250.0, 0.8, 0.6))
state.value // the ColorValue, in the space it was last written in
state.color // as a Compose Color, mapped into sRGB
state[Hsl.H] // one channel, converted; null for a grey's hue
state.displayValue(Hsl.H) // what a slider shows: for a grey, the hue last chosen
state.hsl.l // a typed view; there is one for each of the fifteen spaces
state.value = OkLch(0.7, 0.15, 140.0)
state[Hsl.L] = 40.0 // leaves the color in HSL
state.set(state.okhsl.with(l = 0.4))
state.value = state.value.withAlpha(0.5)
state.isInteracting // true while a slider or plane is being draggedA grey has no hue, so a hue slider has nothing to show for one. The state remembers the last
hue chosen and shows that, and an edit that makes a grey colorful writes it back, so a color the
user darkened or desaturated comes back in the hue they had rather than red. A grey arriving
without a hue β from hex, a Compose Color or an sRGB value β leaves what is remembered alone.
A hue chosen in one space is carried into the others, so an Okhsl slider shows the hue last
picked on an HSL one; that is this library's own rule, as CSS carries no hue across spaces
(csswg-drafts#8484).
Writing a channel that is out of its limit, or not a number, throws.
ColorPickerState has a public constructor, so it can also be created and held outside of
composition (e.g. in a ViewModel). rememberSaveableColorPickerState keeps the value, its space
and the remembered hues across configuration changes and process death, on platforms that
restore saved state. A value in an app's own space restores when that space is among the
knownSpaces it is given.
Color space conversions lose precision: floats round, and a conversion cannot invent what a color does not carry β every hue of a grey is the same sRGB color. Industry-standard tools (CSS Color Level 4, color.js, Sass) solve this the same way we do:
Store colors in their authored color space. Convert forward only. Never convert back.
A ColorValue is always in one space, and the state holds whichever value was last written or
edited. A slider edits in its own channel's space: the color is converted there once, the channel
is set, and the result stays there. Reading another space converts forward, once, from that
value; nothing is re-derived from a conversion.
User drags the OkLCh chroma slider
-> the value is converted into OkLCh once and its chroma set (the value is now OkLCh)
-> the Okhsl sliders read it: one conversion, OkLCh -> Okhsl
-> the OkLCh sliders read it: no conversion at all
This means:
- Editing in a space and reading that space back gives exactly the value set
- Cross-space reads involve a single forward conversion, never a round-trip
- No precision loss accumulates over time, regardless of how many edits are made
- A value outside sRGB and a
nonecomponent are kept until something writes over them
For more details, see:
- CSS Color Module Level 4 -- the W3C spec mandates the same approach
- Sass Color Spaces -- stores colors in their original space
The same sample app runs on every supported platform:
./gradlew :sample:desktopApp:run # desktop window
./gradlew :sample:androidApp:installDebug # device or emulatorsample/iosApp holds the SwiftUI entry points. The Xcode project is not checked in, so
it needs creating once on a Mac against the ComposeApp framework that :sample:shared
produces.
2.0 replaces 1.x's color classes with ColorValue and its per-channel components with ones that
take a channel. The color types live in codes.side.color, which colorpicker-material3 brings with it.
| 1.x | 2.0 | Note |
|---|---|---|
implementation("codes.side:colorpicker:1.2.1") |
implementation("codes.side:colorpicker-material3:2.0.0") |
codes.side:colorpicker stops at 1.2.1 |
codes.side.colorpicker.ui.*, codes.side.colorpicker.theme.* |
codes.side.colorpicker.material3.* |
|
HslColor(hue = 200f, saturation = 0.8f, lightness = 0.5f) |
Hsl(200.0, 80.0, 50.0) |
CSS's units: HSL's S and L are 0β100 |
RgbColor, CmykColor, LabColor, OkhslColor, OkhsvColor, OklabColor, OklchColor |
Srgb(β¦), Cmyk(β¦), Lab(β¦), Okhsl(β¦), Okhsv(β¦), Oklab(β¦), OkLch(β¦) |
each a ColorValue |
hsl.toRgb(), rgb.toOklch(), β¦ |
value.to(Srgb), value.to(OkLch) |
|
codes.side.colorpicker.conversion.HexAlpha |
codes.side.color.HexAlpha |
|
color.toHexString(HexAlpha.First) |
value.toHexString(HexAlpha.First) |
|
"#3380CC".toRgbColorOrNull(HexAlpha.None) |
ColorValue.parseHexOrNull("#3380CC", HexAlpha.None) |
|
hsl.toComposeColor(), color.toHslColor() |
value.toComposeColor(), color.toColorValue().to(Hsl) |
|
ColorPickerState(initialColor: PickerColor = HslColor()) |
ColorPickerState(initialValue: ColorValue) or (initialColor: Color) |
no default color |
state.hslColor, rgbColor, β¦ (8) |
state.hsl, state.srgb, β¦ (15 typed views) or state.value.to(Hsl) |
units are CSS's |
state.pickerColor |
state.value |
|
state.argbInt |
state.color.toArgb() |
|
updateHue(h), updateRed(r), β¦ (30) |
state[Hsl.H] = h, state[Srgb.R] = r |
NaN and out-of-limit values throw instead of being ignored |
updateFromHsl(hsl), β¦ |
state.value = x or state.set(view) |
|
updateAlpha(a) |
state.value = state.value.withAlpha(a) |
|
updateFromArgbInt(i) |
state.value = Color(i).toColorValue() |
|
HueSlider(state), RedSlider(state), β¦ (21) |
ChannelSlider(state, Hsl.H), ChannelSlider(state, Srgb.R) |
|
HslPlane, OkhslPlane, OkhsvPlane |
ChannelPlane(state, Hsl.S, Hsl.L), β¦ |
|
PlaneActionLabels.Default |
ColorPickerStrings.current.planeAxisActions() |
|
HslColorPicker(color: HslColor, onColorChange) |
HslColorPicker(value: ColorValue, onValueChange) or (color: Color, onColorChange) |
fully controlled |
| a caller's value applied when the gesture ends | applied at once; the callback is synchronous | |
per-channel slots (hueSlider = β¦) |
channelSlider = { state, channel -> β¦ } |
|
showAlpha = false |
alphaSlider = null |
plane = null leaves the plane out |
thumb = { source -> MyThumb(source) } |
thumb = { MyThumb(interactionSource) } |
the slot reads its scope |
thumbWidth = 48.dp |
dimensions = ColorPickerDefaults.currentDimensions().copy(thumbWidth = 48.dp) |
|
ColorSlider(gradientColors = persistentListOf(β¦)) |
ColorSlider(trackColors = listOf(β¦)) |
|
LocalColorPickerColors.current, β¦ |
ColorPickerDefaults.currentColors(), β¦ |
provided by ColorPickerTheme |
ColorPickerDialog(onColorSelected: (HslColor) -> Unit, initialColor: HslColor) |
ColorPickerDialog(initialValue, onValueSelected, onDismissRequest) or the Color form |
the title and buttons are slots |
per-component String parameters to localize |
ProvideColorPickerStrings(yourStrings) { β¦ } |
the parameters still win |
| a saved 1.x state | not restored; the state starts from its initial value | |
randomHslColor() |
removed |
The View-based codes.side:andcolorpicker artifact (XML HSLColorPickerSeekBar and friends) is discontinued. This library is a full Compose Multiplatform rewrite published under new coordinates:
- implementation("codes.side:andcolorpicker:0.6.2")
+ implementation("codes.side:colorpicker-material3:2.0.0")There is no 1:1 API mapping β migrate by concept:
| andcolorpicker (View-based) | colorpicker-material3 (Compose) |
|---|---|
HSLColorPickerSeekBar (hslMode = hue/saturation/lightness) |
ChannelSlider(state, Hsl.H), Hsl.S or Hsl.L, or HslColorPicker for all three |
RGBColorPickerSeekBar |
ChannelSlider(state, Srgb.R), Srgb.G or Srgb.B, or RgbColorPicker |
CMYKColorPickerSeekBar |
ChannelSlider(state, Cmyk.C), Cmyk.M, Cmyk.Y or Cmyk.K, or CmykColorPicker |
LABColorPickerSeekBar |
ChannelSlider(state, Lab.L), Lab.A or Lab.B, or LabColorPicker |
HSLAlphaColorPickerSeekBar |
AlphaSlider |
PickerGroup + registerPickers |
Pass one ColorPickerState to every component β they stay in sync automatically |
SwatchView |
ColorSwatch |
OnColorPickListener / addListener |
Read state.value β it is Compose snapshot state, so composition recomposes automatically; use snapshotFlow outside composition |
IntegerHSLColor and friends |
ColorValue, built by its space: Hsl(200.0, 80.0, 50.0) |
hslColoringMode = pure / output |
ColoringMode.Independent / ColoringMode.Contextual |
Copyright 2020 Illia Achour
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.





























