Skip to content

Advanced Topics

The component uses Preact Signals for fine-grained reactivity. State changes update only the affected parts of the UI instead of causing a broad re-render of the whole component.

Styles and internal markup are encapsulated in Shadow DOM. That keeps the picker stable when embedded in very different host apps while still exposing major styling hooks through ::part().

The 2D color area is not a single generic gradient. It changes its model depending on the active editing space:

  • srgb and hex use an OKHSV-style plane
  • hsl uses saturation × lightness
  • oklch, lch, and wide-gamut RGB-like spaces use a chroma × lightness plane
  • oklab and lab use a centered a × b plane
  • hwb uses whiteness × blackness

That choice makes the picker behave more naturally for different color models instead of forcing everything through one generic RGB-oriented interaction.

For spaces where gamut boundaries are especially relevant, the area picker can render overlays for multiple gamuts and adjust its mapping so the useful region fills more of the control.

Examples:

  • oklch-style picking can show row-scanned chroma boundaries across lightness
  • oklab and lab can show polar gamut boundaries across the a × b plane
  • wide-gamut RGB-like spaces are edited through a perceptual plane while still reflecting the chosen output space

This helps the picker communicate when a color fits within sRGB, P3, Rec2020, or beyond.

Color calculations are powered by colorjs.io, which provides the parsing, conversion, gamut checks, and serialization logic behind the UI.

The current implementation uses several practical performance strategies:

  • Signals prevent unnecessary UI work
  • hue-driven area updates are batched with requestAnimationFrame
  • the area gradient is drawn into canvas rather than constructed from many DOM nodes
  • wide-gamut canvas rendering is used when supported and falls back to sRGB otherwise
  • a worker can be used to move expensive area and boundary computation off the main thread
  • boundary overlays are drawn separately from the underlying gradient

The worker-backed path is especially useful for perceptual and wide-gamut modes, where generating the area and boundary data involves many color-space conversions.

The popover automatically positions relative to the trigger button, respects viewport boundaries, and accounts for safe-area constraints.

The implementation has two positioning modes:

  • native CSS anchor positioning when supported and when the internal trigger is the anchor
  • a manual fixed-position fallback when a custom anchor is used or anchor positioning is unavailable

The fallback mode observes relevant layout changes and clamps the panel to the visible viewport.

For custom placement, use setAnchor():

const picker = document.querySelector("color-input");
picker.setAnchor(document.querySelector("#my-anchor"));
picker.show();

The read-only gamut property reports the smallest tracked gamut that contains the current color:

  • srgb — safe for standard displays
  • p3 — requires Display P3 or better
  • rec2020 — requires a wider display pipeline
  • xyz — outside the named RGB gamuts tracked by the picker

A visual badge appears in the UI, and the change event includes event.detail.gamut for programmatic use.

From a developer’s perspective, the component typically does this when values change:

  1. parse the incoming CSS color string
  2. infer or apply the active editing colorspace
  3. convert the color for the relevant controls
  4. serialize a normalized string back to value
  5. emit change with the latest value, colorspace, and gamut

That means value is best treated as the component’s canonical serialized output, not necessarily a byte-for-byte preservation of the original source string.

Several parts of the component are intentionally opportunistic:

  • EyeDropper controls appear only in supporting browsers
  • wide-gamut canvas rendering is used only when supported by the browser’s canvas implementation
  • native anchor positioning is used only when available
  • worker-backed area rendering falls back to synchronous rendering if needed

The goal is to improve the experience in modern browsers without making the component unusable elsewhere.

The component does not extend HTMLInputElement, but it integrates easily with forms through a hidden input:

<form>
<color-input id="picker" value="oklch(70% 20% 240)"></color-input>
<input type="hidden" name="color" id="color-input" />
</form>
<script>
const picker = document.querySelector("#picker");
const hidden = document.querySelector("#color-input");
hidden.value = picker.value;
picker.addEventListener("change", (event) => {
hidden.value = event.detail.value;
});
</script>