css-is-awesome 1.14.3 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,605 @@
1
+ ---
2
+ name: color-picker
3
+ description: A colour input that starts at native <input type="color"> and upgrades to a custom OKLCH picker — hue, chroma, lightness and alpha sliders with a text fallback and a live swatch.
4
+ category: input
5
+ complexity: complex
6
+ cia-version: ">=1.0.0"
7
+ ---
8
+
9
+ ## Use this when
10
+
11
+ You need the user to choose a colour — a brand token in a settings screen, a tag colour, a chart series. Start with the **native variant** (`<input type="color">`): zero JS, the OS picker, keyboard and screen-reader support for free. Upgrade to the **custom variant** only when you need alpha, a wide-gamut / OKLCH value, or a picker whose look you actually control. If the user picks from a short fixed set, bail and use a row of preset swatches (`aria-pressed` buttons) instead — that is a toggle group, not a colour picker. The site's own theme editor at `/themes` is a full-page version of exactly this pattern: it is where cia's colour tokens get edited, and this recipe is the component-sized cut of it.
12
+
13
+ ## Structure (raw HTML)
14
+
15
+ ### Native variant (`<input type="color">`)
16
+
17
+ ```html
18
+ <div class="my-color" data-cia-recipe="color-picker">
19
+ <label for="my-brand" data-slot="label">Brand colour</label>
20
+ <div data-slot="row">
21
+ <input id="my-brand" data-slot="native" type="color" />
22
+ <input
23
+ data-slot="text"
24
+ type="text"
25
+ inputmode="text"
26
+ spellcheck="false"
27
+ autocomplete="off"
28
+ pattern="^#[0-9a-fA-F]{6}$"
29
+ aria-label="Brand colour as hex"
30
+ />
31
+ </div>
32
+ </div>
33
+ ```
34
+
35
+ Notes on the markup:
36
+
37
+ - The native input **is** the value. The paired text field is the keyboard / screen-reader path: it shows the hex the picker holds and accepts a typed one. Both write to the same state.
38
+ - The browser owns the popup: eyedropper, palette, keyboard nav and announcements come from the OS. You cannot style the popup; you can style the swatch button (see Styling).
39
+ - `pattern` makes a malformed hex fail native constraint validation, so the same `:user-invalid` styling the [`form-validation-html5`](./form-validation-html5.md) recipe documents applies here with no extra work.
40
+
41
+ ### Custom variant (OKLCH sliders)
42
+
43
+ ```html
44
+ <div class="my-color" data-cia-recipe="color-picker" role="group" aria-labelledby="my-color-title">
45
+ <span id="my-color-title" data-slot="label">Accent colour</span>
46
+
47
+ <div data-slot="preview">
48
+ <span data-slot="swatch" style="--swatch: oklch(62% 0.18 260 / 1)" aria-hidden="true"></span>
49
+ <output data-slot="value" for="my-hue my-chroma my-light my-alpha" aria-live="off">oklch(62% 0.18 260 / 1)</output>
50
+ </div>
51
+
52
+ <label data-slot="field">
53
+ <span>Hue</span>
54
+ <input data-slot="range" type="range" min="0" max="360" step="1" value="260"
55
+ aria-valuetext="Hue 260 degrees" id="my-hue" />
56
+ </label>
57
+ <label data-slot="field">
58
+ <span>Chroma</span>
59
+ <input data-slot="range" type="range" min="0" max="0.37" step="0.005" value="0.18"
60
+ aria-valuetext="Chroma 0.18" id="my-chroma" />
61
+ </label>
62
+ <label data-slot="field">
63
+ <span>Lightness</span>
64
+ <input data-slot="range" type="range" min="0" max="100" step="1" value="62"
65
+ aria-valuetext="Lightness 62 percent" id="my-light" />
66
+ </label>
67
+ <label data-slot="field">
68
+ <span>Alpha</span>
69
+ <input data-slot="range" type="range" min="0" max="1" step="0.01" value="1"
70
+ aria-valuetext="Alpha 100 percent" id="my-alpha" />
71
+ </label>
72
+
73
+ <label data-slot="field">
74
+ <span>Or type a value</span>
75
+ <input data-slot="text" type="text" spellcheck="false" autocomplete="off"
76
+ placeholder="hex or oklch(…)" />
77
+ </label>
78
+ </div>
79
+ ```
80
+
81
+ Notes on the markup:
82
+
83
+ - The four sliders are real `<input type="range">` elements, so Arrow / Home / End / PageUp / PageDown work natively and each one is a first-class form control. `aria-valuetext` turns the raw number into something meaningful ("Hue 260 degrees" rather than "260").
84
+ - The swatch's `style="--swatch: …"` is a **data binding, not appearance styling** — the live colour is the value being edited, so it has to travel with the element. The SCSS below consumes it via `background: var(--swatch)`; no colour, spacing or type is set inline. (`validate-recipes` flags every inline `style=` so this stays a conscious exception.)
85
+ - `<output>` is the semantic "computed result" element; `aria-live="off"` because every slider already announces its own `aria-valuetext` — a second announcement of the full string on every keystroke is noise, and the value is still readable on demand.
86
+ - The text field is the **numeric fallback**: a keyboard-only or screen-reader user can type `#rrggbb` or an `oklch()` string and the sliders sync to it (see Interactivity).
87
+
88
+ ## Styling (cia mixins)
89
+
90
+ ```scss
91
+ // MyColorPicker.module.scss — component stylesheet, so import the zero-emit barrel.
92
+ @use 'css-is-awesome/api' as cia;
93
+
94
+ .my-color {
95
+ @include cia.stack(3);
96
+ max-inline-size: 22rem;
97
+
98
+ [data-slot="label"] { @include cia.label-base; }
99
+
100
+ [data-slot="row"] { @include cia.cluster(2); }
101
+
102
+ /* Native variant: the swatch button is the only styleable part */
103
+ [data-slot="native"] {
104
+ @include cia.input-base;
105
+ inline-size: 3rem;
106
+ block-size: 2.5rem;
107
+ padding: cia.space(2xs);
108
+ cursor: pointer;
109
+
110
+ &::-webkit-color-swatch-wrapper { padding: 0; }
111
+ &::-webkit-color-swatch { border: 0; border-radius: cia.radius(sm); }
112
+ &::-moz-color-swatch { border: 0; border-radius: cia.radius(sm); }
113
+ }
114
+
115
+ [data-slot="text"] {
116
+ @include cia.input-base;
117
+ font-family: cia.font-family(mono);
118
+ flex: 1;
119
+ }
120
+
121
+ /* Custom variant */
122
+ [data-slot="preview"] { @include cia.cluster(3); }
123
+
124
+ [data-slot="swatch"] {
125
+ inline-size: 3rem;
126
+ block-size: 3rem;
127
+ border-radius: cia.radius(md);
128
+ border: 1px solid cia.color(border-default);
129
+ box-shadow: cia.shadow(1);
130
+ /* The live colour arrives on the element as --swatch (see Structure) */
131
+ background: var(--swatch);
132
+ /* Checkerboard under the colour so alpha is visible */
133
+ background-image: linear-gradient(var(--swatch), var(--swatch)),
134
+ repeating-conic-gradient(cia.color(surface-muted) 0 25%, cia.color(surface-default) 0 50%);
135
+ background-size: 100% 100%, 0.75rem 0.75rem;
136
+ }
137
+
138
+ [data-slot="value"] {
139
+ font-family: cia.font-family(mono);
140
+ font-size: cia.font-size(2);
141
+ color: cia.color(text-secondary);
142
+ }
143
+
144
+ [data-slot="field"] {
145
+ @include cia.stack(1);
146
+ > span { @include cia.label-base($size: 1, $color: text-secondary); }
147
+ }
148
+
149
+ [data-slot="range"] {
150
+ @include cia.slider-base;
151
+ }
152
+
153
+ /* Hue gets a real rainbow track so the slider explains itself */
154
+ [data-slot="range"][id$='hue'] {
155
+ &::-webkit-slider-runnable-track,
156
+ &::-moz-range-track {
157
+ background: linear-gradient(in oklch longer hue, oklch(70% 0.15 0), oklch(70% 0.15 360));
158
+ }
159
+ }
160
+ }
161
+ ```
162
+
163
+ `cia.slider-base` styles the track and thumb for both engines and adds the focus ring; the recipe only overrides the hue track. `cia.input-base` on `<input type="color">` gives the swatch button the same border, radius and focus treatment as every other field — the popup itself ignores author CSS in every browser.
164
+
165
+ ## Interactivity
166
+
167
+ Native variant: **zero JS** for the picker. One tiny sync keeps the text field honest: on the colour input's `input` event write `value` into the text field; on the text field's `change`, if it matches `/^#[0-9a-f]{6}$/i`, write it back into the colour input.
168
+
169
+ Custom variant: the consumer script owns four jobs, all small:
170
+
171
+ 1. **Compose** — on any slider `input`, build `oklch(L% C H / A)` from the four values, write it to `--swatch` on the swatch element and to the `<output>`.
172
+ 2. **Describe** — update each slider's `aria-valuetext` from its value ("Hue 260 degrees", "Lightness 62 percent").
173
+ 3. **Parse** — on the text field's `change`, accept either `#rrggbb` (convert sRGB → OKLCH, below) or an `oklch()` string, and move all four sliders to match. Reject anything else by leaving the sliders alone and marking the field `aria-invalid="true"`.
174
+ 4. **Emit** — fire your `change` callback with the `oklch()` string (and, if the consumer needs it, the nearest hex via the reverse conversion).
175
+
176
+ **Why OKLCH, not HSL.** HSL's lightness is not perceptual: `hsl(60 100% 50%)` (yellow) and `hsl(240 100% 50%)` (blue) claim the same lightness and are nowhere near it. OKLCH's L is perceptually uniform, so a lightness slider *looks* linear, and its hue stays stable as chroma changes — which is exactly why cia's own tokens are mixed with `color-mix()` in OKLCH. Browser floor for `oklch()` is Chrome ≥ 111, Safari ≥ 15.4, Firefox ≥ 113; for older engines swap the three colour sliders for HSL (see Variants) — the markup and the a11y work are identical.
177
+
178
+ The conversion is ~30 lines of plain arithmetic (Björn Ottosson's published matrices); every framework example below imports it from this one module:
179
+
180
+ ```js
181
+ // color-math.js — sRGB <-> OKLCH, no dependencies.
182
+ const lin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
183
+ const gam = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
184
+ const clamp01 = (x) => Math.min(1, Math.max(0, x));
185
+
186
+ /** "#rrggbb" -> { l: 0-100, c: 0-0.4, h: 0-360 } */
187
+ export function hexToOklch(hex) {
188
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
189
+ if (!m) return null;
190
+ const [r, g, b] = [0, 2, 4].map((i) => lin(parseInt(m[1].slice(i, i + 2), 16) / 255));
191
+ const l_ = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
192
+ const m_ = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
193
+ const s_ = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
194
+ const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
195
+ const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
196
+ const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
197
+ const h = ((Math.atan2(bb, a) * 180) / Math.PI + 360) % 360;
198
+ return { l: L * 100, c: Math.hypot(a, bb), h };
199
+ }
200
+
201
+ /** { l, c, h } -> "#rrggbb" (gamut-clipped to sRGB) */
202
+ export function oklchToHex({ l, c, h }) {
203
+ const a = c * Math.cos((h * Math.PI) / 180);
204
+ const bb = c * Math.sin((h * Math.PI) / 180);
205
+ const L = l / 100;
206
+ const l_ = (L + 0.3963377774 * a + 0.2158037573 * bb) ** 3;
207
+ const m_ = (L - 0.1055613458 * a - 0.0638541728 * bb) ** 3;
208
+ const s_ = (L - 0.0894841775 * a - 1.291485548 * bb) ** 3;
209
+ const rgb = [
210
+ 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
211
+ -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
212
+ -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
213
+ ];
214
+ return "#" + rgb.map((v) => Math.round(clamp01(gam(v)) * 255).toString(16).padStart(2, "0")).join("");
215
+ }
216
+
217
+ export function oklchToString({ l, c, h }, alpha = 1) {
218
+ return `oklch(${l.toFixed(0)}% ${c.toFixed(3)} ${h.toFixed(0)} / ${alpha})`;
219
+ }
220
+
221
+ /** Accepts "#rrggbb" or "oklch(L C H / A)"; returns { l, c, h, alpha } or null */
222
+ export function parseColor(text) {
223
+ const fromHex = hexToOklch(text);
224
+ if (fromHex) return { ...fromHex, alpha: 1 };
225
+ const m = /^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+)(%?))?\s*\)$/i.exec(text.trim());
226
+ if (!m) return null;
227
+ const l = m[2] ? Number(m[1]) : Number(m[1]) * 100;
228
+ const alpha = m[5] === undefined ? 1 : m[6] ? Number(m[5]) / 100 : Number(m[5]);
229
+ return { l, c: Number(m[3]), h: Number(m[4]) % 360, alpha };
230
+ }
231
+ ```
232
+
233
+ Edge cases:
234
+
235
+ - **SSR:** render the sliders with their default `value` attributes and the swatch's `--swatch` already composed on the server — the first paint is correct before hydration, and nothing here touches `window` at module load.
236
+ - **Out-of-gamut:** OKLCH can express colours sRGB cannot show. `oklchToHex` clips; if you show the hex alongside the OKLCH string, say so in the UI ("nearest sRGB").
237
+ - **Dragging performance:** compose on `input`, but debounce the *emit* to `change` (pointer-up) if the consumer re-renders something expensive on every value.
238
+
239
+ ## A11y checklist
240
+
241
+ - [ ] The native `<input type="color">` has a visible, associated `<label>` — not just a placeholder swatch ([WCAG 2.2 SC 1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html))
242
+ - [ ] Every slider is a real `<input type="range">` with an accessible name (wrapping `<label>` or `aria-labelledby`) so it gets the slider role, Arrow / Home / End keys and value announcements natively ([APG Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/))
243
+ - [ ] Each slider carries `aria-valuetext` that says what the number means ("Hue 260 degrees", "Alpha 40 percent"), updated on every change ([WCAG 2.2 SC 4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html))
244
+ - [ ] A text field accepts a typed hex / `oklch()` value and syncs the sliders — keyboard-only and screen-reader users never depend on dragging ([WCAG 2.2 SC 2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html))
245
+ - [ ] The chosen colour is exposed as text (`<output>` / the text field), not only as the swatch's fill ([WCAG 2.2 SC 1.4.1 Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html))
246
+ - [ ] A rejected text value sets `aria-invalid="true"` and an adjacent message says why, rather than silently ignoring the input ([WCAG 2.2 SC 3.3.1 Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html))
247
+ - [ ] Slider thumbs meet the 24 × 24 px minimum target size (`cia.slider-base`'s default thumb is 1.1rem ≈ 17.6px — raise `$thumb-size` to `1.5rem` for pointer-heavy UIs) ([WCAG 2.2 SC 2.5.8 Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html))
248
+ - [ ] The swatch is `aria-hidden` — it is a preview of a value that is already announced, not information of its own ([WAI-ARIA: aria-hidden](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden))
249
+
250
+ ## Framework examples
251
+
252
+ All four examples implement the custom OKLCH variant: four sliders, a live swatch, an `<output>`, and a text field that accepts hex or `oklch()`. Each imports the `color-math.js` module from Interactivity. The native variant needs no framework code beyond binding `value`.
253
+
254
+ ### React
255
+
256
+ ```tsx
257
+ "use client";
258
+ import { useState } from "react";
259
+ import { parseColor, oklchToString } from "./color-math";
260
+ import styles from "./MyColorPicker.module.scss";
261
+
262
+ type Oklch = { l: number; c: number; h: number; alpha: number };
263
+ const DEFAULT: Oklch = { l: 62, c: 0.18, h: 260, alpha: 1 };
264
+
265
+ export default function MyColorPicker({ onChange }: { onChange?: (value: string) => void }) {
266
+ const [color, setColor] = useState<Oklch>(DEFAULT);
267
+ const [text, setText] = useState("");
268
+ const [invalid, setInvalid] = useState(false);
269
+ const value = oklchToString(color, color.alpha);
270
+
271
+ function update(patch: Partial<Oklch>) {
272
+ const next = { ...color, ...patch };
273
+ setColor(next);
274
+ onChange?.(oklchToString(next, next.alpha));
275
+ }
276
+
277
+ function commitText() {
278
+ const parsed = parseColor(text);
279
+ if (!parsed) return setInvalid(true);
280
+ setInvalid(false);
281
+ update(parsed);
282
+ }
283
+
284
+ const sliders = [
285
+ { key: "h", label: "Hue", min: 0, max: 360, step: 1, text: `Hue ${Math.round(color.h)} degrees` },
286
+ { key: "c", label: "Chroma", min: 0, max: 0.37, step: 0.005, text: `Chroma ${color.c.toFixed(3)}` },
287
+ { key: "l", label: "Lightness", min: 0, max: 100, step: 1, text: `Lightness ${Math.round(color.l)} percent` },
288
+ { key: "alpha", label: "Alpha", min: 0, max: 1, step: 0.01, text: `Alpha ${Math.round(color.alpha * 100)} percent` },
289
+ ] as const;
290
+
291
+ return (
292
+ <div className={styles.myColor} role="group" aria-labelledby="my-color-title">
293
+ <span id="my-color-title">Accent colour</span>
294
+ <div className={styles.preview}>
295
+ <span className={styles.swatch} style={{ ["--swatch" as string]: value }} aria-hidden="true" />
296
+ <output aria-live="off">{value}</output>
297
+ </div>
298
+ {sliders.map((s) => (
299
+ <label key={s.key} className={styles.field}>
300
+ <span>{s.label}</span>
301
+ <input
302
+ type="range"
303
+ min={s.min}
304
+ max={s.max}
305
+ step={s.step}
306
+ value={color[s.key]}
307
+ aria-valuetext={s.text}
308
+ id={s.key === "h" ? "my-hue" : undefined}
309
+ onChange={(e) => update({ [s.key]: Number(e.target.value) })}
310
+ />
311
+ </label>
312
+ ))}
313
+ <label className={styles.field}>
314
+ <span>Or type a value</span>
315
+ <input
316
+ type="text"
317
+ spellCheck={false}
318
+ placeholder="hex or oklch(…)"
319
+ value={text}
320
+ aria-invalid={invalid || undefined}
321
+ onChange={(e) => setText(e.target.value)}
322
+ onBlur={commitText}
323
+ onKeyDown={(e) => e.key === "Enter" && commitText()}
324
+ />
325
+ </label>
326
+ {invalid && <p role="alert">Enter a 6-digit hex or an oklch() value.</p>}
327
+ </div>
328
+ );
329
+ }
330
+ ```
331
+
332
+ ### Vue
333
+
334
+ ```vue
335
+ <script setup>
336
+ import { computed, reactive, ref } from "vue";
337
+ import { parseColor, oklchToString } from "./color-math";
338
+
339
+ const emit = defineEmits(["change"]);
340
+ const color = reactive({ l: 62, c: 0.18, h: 260, alpha: 1 });
341
+ const text = ref("");
342
+ const invalid = ref(false);
343
+ const value = computed(() => oklchToString(color, color.alpha));
344
+
345
+ const sliders = [
346
+ { key: "h", label: "Hue", min: 0, max: 360, step: 1, text: () => `Hue ${Math.round(color.h)} degrees` },
347
+ { key: "c", label: "Chroma", min: 0, max: 0.37, step: 0.005, text: () => `Chroma ${color.c.toFixed(3)}` },
348
+ { key: "l", label: "Lightness", min: 0, max: 100, step: 1, text: () => `Lightness ${Math.round(color.l)} percent` },
349
+ { key: "alpha", label: "Alpha", min: 0, max: 1, step: 0.01, text: () => `Alpha ${Math.round(color.alpha * 100)} percent` },
350
+ ];
351
+
352
+ function set(key, v) {
353
+ color[key] = Number(v);
354
+ emit("change", value.value);
355
+ }
356
+ function commitText() {
357
+ const parsed = parseColor(text.value);
358
+ invalid.value = !parsed;
359
+ if (parsed) {
360
+ Object.assign(color, parsed);
361
+ emit("change", value.value);
362
+ }
363
+ }
364
+ </script>
365
+
366
+ <template>
367
+ <div class="my-color" role="group" aria-labelledby="my-color-title">
368
+ <span id="my-color-title" data-slot="label">Accent colour</span>
369
+ <div data-slot="preview">
370
+ <span data-slot="swatch" :style="{ '--swatch': value }" aria-hidden="true"></span>
371
+ <output data-slot="value" aria-live="off">{{ value }}</output>
372
+ </div>
373
+ <label v-for="s in sliders" :key="s.key" data-slot="field">
374
+ <span>{{ s.label }}</span>
375
+ <input
376
+ data-slot="range"
377
+ type="range"
378
+ :min="s.min"
379
+ :max="s.max"
380
+ :step="s.step"
381
+ :value="color[s.key]"
382
+ :aria-valuetext="s.text()"
383
+ :id="s.key === 'h' ? 'my-hue' : undefined"
384
+ @input="set(s.key, $event.target.value)"
385
+ />
386
+ </label>
387
+ <label data-slot="field">
388
+ <span>Or type a value</span>
389
+ <input
390
+ data-slot="text"
391
+ type="text"
392
+ spellcheck="false"
393
+ placeholder="hex or oklch(…)"
394
+ v-model="text"
395
+ :aria-invalid="invalid || undefined"
396
+ @blur="commitText"
397
+ @keydown.enter="commitText"
398
+ />
399
+ </label>
400
+ <p v-if="invalid" role="alert">Enter a 6-digit hex or an oklch() value.</p>
401
+ </div>
402
+ </template>
403
+ ```
404
+
405
+ ### Svelte
406
+
407
+ ```svelte
408
+ <script>
409
+ import { createEventDispatcher } from "svelte";
410
+ import { parseColor, oklchToString } from "./color-math";
411
+
412
+ const dispatch = createEventDispatcher();
413
+ let color = { l: 62, c: 0.18, h: 260, alpha: 1 };
414
+ let text = "";
415
+ let invalid = false;
416
+ $: value = oklchToString(color, color.alpha);
417
+
418
+ const sliders = [
419
+ { key: "h", label: "Hue", min: 0, max: 360, step: 1, text: (c) => `Hue ${Math.round(c.h)} degrees` },
420
+ { key: "c", label: "Chroma", min: 0, max: 0.37, step: 0.005, text: (c) => `Chroma ${c.c.toFixed(3)}` },
421
+ { key: "l", label: "Lightness", min: 0, max: 100, step: 1, text: (c) => `Lightness ${Math.round(c.l)} percent` },
422
+ { key: "alpha", label: "Alpha", min: 0, max: 1, step: 0.01, text: (c) => `Alpha ${Math.round(c.alpha * 100)} percent` },
423
+ ];
424
+
425
+ function set(key, v) {
426
+ color = { ...color, [key]: Number(v) };
427
+ dispatch("change", oklchToString(color, color.alpha));
428
+ }
429
+ function commitText() {
430
+ const parsed = parseColor(text);
431
+ invalid = !parsed;
432
+ if (parsed) {
433
+ color = parsed;
434
+ dispatch("change", oklchToString(color, color.alpha));
435
+ }
436
+ }
437
+ </script>
438
+
439
+ <div class="my-color" role="group" aria-labelledby="my-color-title">
440
+ <span id="my-color-title" data-slot="label">Accent colour</span>
441
+ <div data-slot="preview">
442
+ <span data-slot="swatch" style="--swatch: {value}" aria-hidden="true"></span>
443
+ <output data-slot="value" aria-live="off">{value}</output>
444
+ </div>
445
+ {#each sliders as s (s.key)}
446
+ <label data-slot="field">
447
+ <span>{s.label}</span>
448
+ <input
449
+ data-slot="range"
450
+ type="range"
451
+ min={s.min}
452
+ max={s.max}
453
+ step={s.step}
454
+ value={color[s.key]}
455
+ aria-valuetext={s.text(color)}
456
+ id={s.key === "h" ? "my-hue" : undefined}
457
+ on:input={(e) => set(s.key, e.currentTarget.value)}
458
+ />
459
+ </label>
460
+ {/each}
461
+ <label data-slot="field">
462
+ <span>Or type a value</span>
463
+ <input
464
+ data-slot="text"
465
+ type="text"
466
+ spellcheck="false"
467
+ placeholder="hex or oklch(…)"
468
+ bind:value={text}
469
+ aria-invalid={invalid || undefined}
470
+ on:blur={commitText}
471
+ on:keydown={(e) => e.key === "Enter" && commitText()}
472
+ />
473
+ </label>
474
+ {#if invalid}<p role="alert">Enter a 6-digit hex or an oklch() value.</p>{/if}
475
+ </div>
476
+ ```
477
+
478
+ ### Vanilla (Web Component)
479
+
480
+ ```js
481
+ import { parseColor, oklchToString } from "./color-math.js";
482
+
483
+ const SLIDERS = [
484
+ { key: "h", label: "Hue", min: 0, max: 360, step: 1, text: (c) => `Hue ${Math.round(c.h)} degrees` },
485
+ { key: "c", label: "Chroma", min: 0, max: 0.37, step: 0.005, text: (c) => `Chroma ${c.c.toFixed(3)}` },
486
+ { key: "l", label: "Lightness", min: 0, max: 100, step: 1, text: (c) => `Lightness ${Math.round(c.l)} percent` },
487
+ { key: "alpha", label: "Alpha", min: 0, max: 1, step: 0.01, text: (c) => `Alpha ${Math.round(c.alpha * 100)} percent` },
488
+ ];
489
+
490
+ class MyColorPicker extends HTMLElement {
491
+ color = { l: 62, c: 0.18, h: 260, alpha: 1 };
492
+
493
+ connectedCallback() {
494
+ this.innerHTML = `
495
+ <div class="my-color" role="group" aria-labelledby="my-color-title">
496
+ <span id="my-color-title" data-slot="label">Accent colour</span>
497
+ <div data-slot="preview">
498
+ <span data-slot="swatch" aria-hidden="true"></span>
499
+ <output data-slot="value" aria-live="off"></output>
500
+ </div>
501
+ ${SLIDERS.map(
502
+ (s) => `<label data-slot="field"><span>${s.label}</span>
503
+ <input data-slot="range" type="range" min="${s.min}" max="${s.max}" step="${s.step}"
504
+ data-key="${s.key}" ${s.key === "h" ? 'id="my-hue"' : ""} /></label>`,
505
+ ).join("")}
506
+ <label data-slot="field"><span>Or type a value</span>
507
+ <input data-slot="text" type="text" spellcheck="false" placeholder="hex or oklch(…)" /></label>
508
+ <p role="alert" hidden>Enter a 6-digit hex or an oklch() value.</p>
509
+ </div>`;
510
+
511
+ this.querySelectorAll('[data-slot="range"]').forEach((input) => {
512
+ input.addEventListener("input", () => {
513
+ this.color = { ...this.color, [input.dataset.key]: Number(input.value) };
514
+ this.render();
515
+ this.dispatchEvent(new CustomEvent("change", { detail: this.value }));
516
+ });
517
+ });
518
+
519
+ const text = this.querySelector('[data-slot="text"]');
520
+ const commit = () => {
521
+ const parsed = parseColor(text.value);
522
+ this.querySelector("[role='alert']").hidden = Boolean(parsed);
523
+ if (parsed) {
524
+ text.removeAttribute("aria-invalid");
525
+ this.color = parsed;
526
+ this.render();
527
+ this.dispatchEvent(new CustomEvent("change", { detail: this.value }));
528
+ } else {
529
+ text.setAttribute("aria-invalid", "true");
530
+ }
531
+ };
532
+ text.addEventListener("change", commit);
533
+ this.render();
534
+ }
535
+
536
+ get value() {
537
+ return oklchToString(this.color, this.color.alpha);
538
+ }
539
+
540
+ render() {
541
+ this.querySelector('[data-slot="swatch"]').style.setProperty("--swatch", this.value);
542
+ this.querySelector('[data-slot="value"]').textContent = this.value;
543
+ this.querySelectorAll('[data-slot="range"]').forEach((input) => {
544
+ const s = SLIDERS.find((x) => x.key === input.dataset.key);
545
+ input.value = String(this.color[s.key]);
546
+ input.setAttribute("aria-valuetext", s.text(this.color));
547
+ });
548
+ }
549
+ }
550
+ customElements.define("my-color-picker", MyColorPicker);
551
+ ```
552
+
553
+ ## Variants
554
+
555
+ ### HSL fallback (pre-OKLCH engines)
556
+
557
+ Same markup, same a11y — only the value composition changes. Chroma becomes Saturation (0–100 %), and the `oklch()` string becomes `hsl(H S% L% / A)`. Because HSL lightness is not perceptual, expect the lightness slider to feel uneven across hues; that is the trade, not a bug.
558
+
559
+ ```scss
560
+ // Only the hue track changes — HSL engines can't do `in oklch longer hue`.
561
+ .my-color [data-slot="range"][id$='hue'] {
562
+ &::-webkit-slider-runnable-track,
563
+ &::-moz-range-track {
564
+ background: linear-gradient(
565
+ to right,
566
+ hsl(0 80% 60%), hsl(60 80% 60%), hsl(120 80% 60%),
567
+ hsl(180 80% 60%), hsl(240 80% 60%), hsl(300 80% 60%), hsl(360 80% 60%)
568
+ );
569
+ }
570
+ }
571
+ ```
572
+
573
+ ### Preset swatches beside the picker
574
+
575
+ A row of `<button aria-pressed>` swatches (each with its `--swatch` binding and a text `aria-label`) for the "pick from the brand palette, or customise" case. The active one is `aria-pressed="true"`; clicking one calls the same `update()` as the sliders.
576
+
577
+ ```scss
578
+ .my-color [data-slot="presets"] {
579
+ @include cia.cluster(1);
580
+
581
+ button {
582
+ @include cia.button-reset;
583
+ inline-size: 1.75rem;
584
+ block-size: 1.75rem;
585
+ border-radius: cia.radius(full);
586
+ border: 2px solid transparent;
587
+ background: var(--swatch);
588
+ @include cia.focus-ring;
589
+
590
+ &[aria-pressed="true"] { border-color: cia.color(action-primary-default); }
591
+ }
592
+ }
593
+ ```
594
+
595
+ ## Pitfalls
596
+
597
+ - **Don't try to style the native popup.** `::-webkit-color-swatch` reaches the swatch *button*; the picker that opens is OS/browser chrome in every engine. If the design needs a styled panel, that is the custom variant, full stop.
598
+ - **Don't use `<input type="color">` for alpha.** It is opaque sRGB hex only, by spec. Alpha means the custom variant.
599
+ - **Don't ship `oklch()` without the floor check.** Below Chrome 111 / Safari 15.4 / Firefox 113 the whole colour declaration is dropped and the swatch renders transparent. Feature-detect with `CSS.supports("color", "oklch(50% 0.1 0)")` and fall back to the HSL variant.
600
+ - **Don't announce the composed string on every keystroke.** Leave `<output>` at `aria-live="off"`; the slider's own `aria-valuetext` already tells the user what changed.
601
+ - **Don't drop the text fallback.** A slider-only picker is unusable with a switch device or a screen reader that doesn't expose range drag well — the typed path is the accessible path, not a convenience.
602
+
603
+ ## Related recipes
604
+
605
+ - [`form-validation-html5`](./form-validation-html5.md) — the `:user-invalid` styling the native variant's hex field inherits for free