Wider gamuts
The picker works in sRGB by default and ships nothing else. Passgamut to work in a wider space instead.
import { P3 } from "@oklch-picker/core/gamuts";
<ColourPicker value={colour} onChange={setColour} gamut={P3} /><script setup>
import { P3 } from "@oklch-picker/core/gamuts";
</script>
<template>
<ColourPicker v-model="colour" :gamut="P3" />
</template><script>
import { P3 } from "@oklch-picker/core/gamuts";
</script>
<ColourPicker bind:value={colour} gamut={P3} />import { P3 } from "@oklch-picker/core/gamuts";
<ColourPicker value={colour()} onChange={setColour} gamut={P3} />import { P3 } from "@oklch-picker/core/gamuts";
// In the component: readonly gamut = signal(P3);
<oklch-colour-picker
[value]="colour()"
(valueChange)="colour.set($event)"
[gamut]="gamut()"
/>// An id, not the object. Qwik serialises props to resume a
// component, and a gamut carries a function, so the id crosses
// the boundary where the object cannot.
<ColourPicker value={colour.value} gamut="p3" onChange$={(c) => (colour.value = c)} />---
import "@oklch-picker/core/styles.css";
---
<oklch-picker value="oklch(0.7 0.15 255)"></oklch-picker>
<script>
import "oklch-picker/register";
import { P3 } from "@oklch-picker/core/gamuts";
// A property, not an attribute: a Gamut is an object.
document.querySelector("oklch-picker").gamut = P3;
<\/script>import { P3 } from "@oklch-picker/core/gamuts";
// A gamut is an object, so it is a property rather than an attribute.
document.querySelector("oklch-picker").gamut = P3;That is the output space, not decoration: the chroma slider reaches further, the value is clamped to P3 rather than sRGB, and the notice only fires once a colour leaves P3 too. Choosing P3 and then still emitting an sRGB colour would defeat the point of choosing it.
Compare them
The same colour in each space. Drag chroma to the top of its track in each and watch how much further it reaches. sRGB stays outlined on the wider charts as a reference, so the safe region remains visible.
Only spaces narrower than the output draw a line. A line for the output itself would trace its own boundary, and a wider one would mark colours the picker cannot reach. So sRGB draws none, P3 draws sRGB, and Rec. 2020 draws both.
Override which spaces are outlined with references, or turn the lines off entirely withparts={{ gamutLines: false }}. The chart keeps its scale either way, so turning them off does not resize the plot.
P3 and REC2020 live behind their own entry point on purpose. An app that never imports them never ships the matrices. The bundler drops the module statically, so there is no dynamic import and nothing async in the render path. Opting in costs a few hundred bytes.
Letting the user switch
Off by default. Turn it on and the picker renders a small segmented control:
const [gamut, setGamut] = useState(SRGB);
<ColourPicker
value={colour}
onChange={setColour}
gamut={gamut}
onGamutChange={setGamut}
gamutChoices={[SRGB, P3, REC2020]}
parts={{ gamutSwitch: true }}
/><ColourPicker
v-model="colour"
:gamut="gamut"
:gamut-choices="[SRGB, P3, REC2020]"
:parts="{ gamutSwitch: true }"
@gamut-change="gamut = $event"
/><ColourPicker
bind:value={colour}
gamut={gamut}
gamutChoices={[SRGB, P3, REC2020]}
parts={{ gamutSwitch: true }}
ongamutchange={(g) => (gamut = g)}
/><ColourPicker
value={colour()}
onChange={setColour}
gamut={gamut()}
onGamutChange={setGamut}
gamutChoices={[SRGB, P3, REC2020]}
parts={{ gamutSwitch: true }}
/><oklch-colour-picker
[value]="colour()"
(valueChange)="colour.set($event)"
[gamut]="gamut()"
(gamutChange)="gamut.set($event)"
[gamutChoices]="choices"
[parts]="{ gamutSwitch: true }"
/>// Ids, not Gamut objects: Qwik serialises props, and a Gamut
// carries fromLms.
<ColourPicker
value={colour.value}
onChange$={$((c: string) => (colour.value = c))}
gamut={gamut.value}
onGamutChange$={$((g: GamutId) => (gamut.value = g))}
gamutChoices={["srgb", "p3", "rec2020"]}
parts={{ gamutSwitch: true }}
/>---
import "@oklch-picker/core/styles.css";
---
<oklch-picker parts='{"gamutSwitch": true}'></oklch-picker>
<script>
import "oklch-picker/register";
import { SRGB } from "@oklch-picker/core";
import { P3, REC2020 } from "@oklch-picker/core/gamuts";
const picker = document.querySelector("oklch-picker");
picker.gamutChoices = [SRGB, P3, REC2020];
picker.addEventListener("gamutchange", (event) => {
console.log(event.detail.gamut.label);
});
<\/script>import { SRGB } from "@oklch-picker/core";
import { P3, REC2020 } from "@oklch-picker/core/gamuts";
const picker = document.querySelector("oklch-picker");
picker.gamut = SRGB;
picker.gamutChoices = [SRGB, P3, REC2020];
picker.parts = { gamutSwitch: true };
picker.addEventListener("gamutchange", (event) => {
picker.gamut = event.detail.gamut;
});gamutChoices defaults to the output gamut plus its references. The control hides itself when that leaves only one option, because one option is not a choice.
Notices
When a colour falls outside the output gamut the picker says so. The wording comes from labels, and there are two keys:
outOfGamutis the sRGB message. It is also the fallback for any space with no wording of its own. It defaults to"Outside sRGB, the nearest sRGB colour is used."outOf:<gamut id>is the message for one output space. Withgamut={P3}the default becomes "Outside Display P3, the nearest Display P3 colour is used."
Every message names its own space rather than saying "outside what a screen can display": P3 is a screen too, so that phrasing was only ever true while sRGB was the only option.
<ColourPicker
labels={{
outOfGamut: "Not displayable.",
"outOf:p3": "Needs a wide-gamut screen.",
}}
/><ColourPicker
v-model="colour"
:labels='{
outOfGamut: "Not displayable.",
"outOf:p3": "Needs a wide-gamut screen.",
}'
/><ColourPicker
bind:value={colour}
labels={{
outOfGamut: "Not displayable.",
"outOf:p3": "Needs a wide-gamut screen.",
}}
/><ColourPicker
value={colour()}
onChange={setColour}
labels={{
outOfGamut: "Not displayable.",
"outOf:p3": "Needs a wide-gamut screen.",
}}
/><oklch-colour-picker
[value]="colour()"
(valueChange)="colour.set($event)"
[labels]="{
outOfGamut: 'Not displayable.',
'outOf:p3': 'Needs a wide-gamut screen.',
}"
/><ColourPicker
value={colour.value}
onChange$={$((c: string) => (colour.value = c))}
labels={{
outOfGamut: "Not displayable.",
"outOf:p3": "Needs a wide-gamut screen.",
}}
/><oklch-picker
labels='{"outOfGamut": "Not displayable.", "outOf:p3": "Needs a wide-gamut screen."}'
></oklch-picker><oklch-picker
labels='{"outOfGamut": "Not displayable.", "outOf:p3": "Needs a wide-gamut screen."}'
></oklch-picker>Or turn the message off entirely, leaving the maths untouched. The value is still clamped, the hatching still shows, only the text goes:
Reading the value back out
A wide-gamut colour is a wide-gamut colour only while it stays inoklch(). That is the form the picker emits and the onevalue carries, and it can express everything P3 and Rec. 2020 reach.
The other formats cannot. rgb(), hsl(),hwb() and hex all describe an sRGB colour, so a P3 or Rec. 2020 value written in any of them is clamped to the nearest sRGB one first. Nothing warns you at that point: the string you get back is valid, displayable and simply a different colour.
Only oklch() survives a wide gamut. The helpers below are all sRGB, so a P3 green becomes the nearest sRGB green on the way through. Store oklch() if the wider colour is the point, and convert at the edges where you need a legacy format.
import { formatOklch, formatHsl, oklchToHex } from "@oklch-picker/core";
// A green P3 reaches and sRGB does not.
const green = { l: 0.86, c: 0.28, h: 145 };
formatOklch(green); // "oklch(0.86 0.28 145)" the colour, intact
oklchToHex(green); // "#01fb48" nearest sRGB
formatHsl(green); // "hsl(137.04 99.21% 49.41%)" nearest sRGB
// None of the three takes a gamut, deliberately: a browser reads their
// numbers as sRGB, so there is no way for them to carry a wider colour.import { inGamut, formatHsl, formatOklch, SRGB } from "@oklch-picker/core";
// Ask before converting, if a silent shift would matter.
if (inGamut(colour, SRGB)) {
save(formatHsl(colour));
} else {
// Keep the wider colour, or tell someone it is about to change.
save(formatOklch(colour));
}The same applies to the picker's own fields. The oklch() field shows the value as stored; the rgb() and hex fields show the sRGB colour nearest to it, which is why hex is off by default. SeeParts for turning each on.