oklch-pickerPlaygroundGitHub

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.

sRGB, the default.
Display P3, reaching further into green.
Rec. 2020, the widest of the three.

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.

Rec. 2020 with the reference lines off.

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:

Switch the output space, and the chroma range changes with it.
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:

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:

The notice on, which is the default.
The same colour with parts.notice off. Still clamped, just silent.

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.