Color PickerATM
A color selection control. Built on native <input type="color"> with a styled swatch and hex value display.
On this page (8)
§Default
Color picker with the picked value next to the swatch (hex unless data-format asks for another notation).
§Multiple pickers
§Notation
data-format picks the notation the value is shown in: hex (the default), rgb, hsl or oklch - CSS Color 4 syntax, ready to paste into a stylesheet or token file. oklch is the notation this system's own theme tokens use. Every form round-trips: pasted back into CSS it gives exactly the colour you picked.
§Switchable notation
Let the user choose: a select.color-picker-format inside the picker switches the notation live. An input[data-color-output] inside the picker always holds the value in the chosen notation, so the form submits it that way (the native input keeps #rrggbb under its own name). Click the value to select it for copying.
§Sizes
The box height lands on the shared input ladder via data-size - md is the typical 2.25rem.
§States
Named states via the shared State API, driven per instance through the bound api:
default-setState('default', { value })presets the color through the native input (events fire);getState().config.valuereports the live hex
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/color-picker-{state}.png.
Machine contract - verified against color-picker.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
value | string | — | "#6366f1" | Selected color - the native input[type="color"] value. |
§CSS view file
/* -- Color Picker component ------------------------------------- */@layer components { .color-picker { display: inline-flex; align-items: center; gap: 0.5rem; border: 1px solid var(--input); border-radius: var(--radius-md); background: var(--background); /* the unsized default IS the ladder's md step (see .input): 1.75rem swatch + 2×3px block padding + 2px frame = 36px */ padding: 0.1875rem 0.75rem 0.1875rem 0.25rem; box-shadow: var(--shadow-xs); & input[type="color"] { -webkit-appearance: none; appearance: none; width: 1.75rem; height: 1.75rem; border: 1px solid var(--border); border-radius: var(--radius-sm); cursor: pointer; padding: 0; background: none; &::-webkit-color-swatch-wrapper { padding: 0; } &::-webkit-color-swatch { border: none; border-radius: calc(var(--radius-sm) - 1px); } &::-moz-color-swatch { border: none; border-radius: calc(var(--radius-sm) - 1px); } } /* the value is meant to be used: one click selects all of it for copy; tabular digits keep the width steady while dragging through colours */ & .color-picker-value { font-size: 0.8125rem; font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--muted-foreground); white-space: nowrap; user-select: all; } /* optional notation switcher (hex / rgb / hsl / oklch) at the end */ & .color-picker-format { margin-inline-start: auto; padding: 0 0 0 0.5rem; border: none; border-inline-start: 1px solid var(--border); background: transparent; color: var(--muted-foreground); font-size: 0.75rem; font-family: var(--font-mono); cursor: pointer; &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: var(--radius-sm); } } /* -- Sizes ------------------------------------------------- Swatch + padding re-derived per step so the TOTAL box height lands on the shared input ladder (xs 28 / sm 32 / md 36 / lg 44 / xl 52 px - md is the typical 2.25rem the other inputs use at md). The UA styles form controls border-box, so: total = swatch + 2×padding-block + 2px frame: xs 20+6+2 · sm 24+6+2 · md 28+6+2 · lg 32+10+2 · xl 40+10+2. The unsized default IS md (1.75rem swatch, 0.1875rem block padding = 36px) - exactly like .input defaults to the md step. */ &[data-size="xs"] { padding: 0.1875rem 0.5rem; gap: 0.375rem; & input[type="color"] { width: 1.25rem; height: 1.25rem; } & .color-picker-value { font-size: 0.6875rem; } } &[data-size="sm"] { padding: 0.1875rem 0.625rem; gap: 0.375rem; & input[type="color"] { width: 1.5rem; height: 1.5rem; } & .color-picker-value { font-size: 0.75rem; } } &[data-size="md"] { padding: 0.1875rem 0.75rem; & input[type="color"] { width: 1.75rem; height: 1.75rem; } & .color-picker-value { font-size: 0.8125rem; } } &[data-size="lg"] { padding: 0.3125rem 0.875rem; & input[type="color"] { width: 2rem; height: 2rem; } & .color-picker-value { font-size: 0.875rem; } } &[data-size="xl"] { padding: 0.3125rem 1rem; gap: 0.625rem; & input[type="color"] { width: 2.5rem; height: 2.5rem; } & .color-picker-value { font-size: 1rem; } } }}§JavaScript view file
Color Picker
// -- Color Picker ---------------------------------------------// Shows the picked colour in the notation the page asks for (hex / rgb /// hsl / oklch - data-format, optionally user-switchable), plus the named-state API// (AGENTS.md "State API"). The picker's observable state is the chosen color,// so 'default' carries an optional { value } preset and getState().config// reports the live value.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const colorPickerStates = ['default'];const getInput = (picker) => picker.querySelector('input[type="color"]');/** Notations the picker can report. The native input always holds #rrggbb; * the display (and data-color-output fields) carry the chosen notation. */const COLOR_FORMATS = ['hex', 'rgb', 'hsl', 'oklch'];/** Trim a number to `digits` decimals without trailing zeros (0.20 → "0.2"). */const num = (n: number, digits: number) => String(Number(n.toFixed(digits)));/** * Why: the value is meant to be USED - pasted into a stylesheet, a token file * or brand guidelines written in another notation. Converts the native * input's #rrggbb in CSS Color 4 syntax: rgb(99 102 241), * hsl(238.7 83.5% 66.7%), oklch(0.5854 0.2041 277.12) - the same shape as * this system's own oklch tokens. OKLCH via linear sRGB → OKLab (Björn * Ottosson's matrices); achromatic colours report chroma and hue as 0. * Precision is MEASURED, not cosmetic: pasting the text back must give the * picked colour. hsl at 1 decimal round-trips every 8-bit colour exactly * (whole numbers drift up to 5 steps); oklch needs L/C at 4 and H at 2 * decimals to stay within one 8-bit step (3/3/1 drifts up to 8 - visible). * Trailing zeros are trimmed, so round colours stay short: hsl(0 100% 50%). */function formatColor(hex: string, format: string): string { const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); if (!m) return hex; const int = parseInt(m[1], 16); const [r, g, b] = [(int >> 16) & 255, (int >> 8) & 255, int & 255]; switch (format) { case 'rgb': return `rgb(${r} ${g} ${b})`; case 'hsl': { const [rn, gn, bn] = [r / 255, g / 255, b / 255]; const max = Math.max(rn, gn, bn); const min = Math.min(rn, gn, bn); const l = (max + min) / 2; const d = max - min; let h = 0; let sat = 0; if (d) { sat = d / (1 - Math.abs(2 * l - 1)); h = max === rn ? ((gn - bn) / d) % 6 : max === gn ? (bn - rn) / d + 2 : (rn - gn) / d + 4; h = (h * 60 + 360) % 360; } return `hsl(${num(h, 1)} ${num(sat * 100, 1)}% ${num(l * 100, 1)}%)`; } case 'oklch': { const lin = (c: number) => { const v = c / 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; const [lr, lg, lb] = [lin(r), lin(g), lin(b)]; const l_ = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); const m_ = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); const s_ = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_; const A = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_; const B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_; const C = Math.hypot(A, B); if (C < 0.00005) return `oklch(${num(L, 4)} 0 0)`; const H = ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360; return `oklch(${num(L, 4)} ${num(C, 4)} ${num(H, 2)})`; } default: return `#${m[1].toLowerCase()}`; }}/** The picker's notation: data-format if it names a known one, else hex. */const formatOf = (picker) => (COLOR_FORMATS.includes(picker.dataset.format) ? picker.dataset.format : 'hex');/** * Render the value in the current notation: the display text, every * input[data-color-output] inside the picker (form submission, change fires) * and the switcher's selection. */function syncValue(picker) { const input = getInput(picker); if (!input) return; const format = formatOf(picker); const text = formatColor(input.value, format); const display = picker.querySelector('.color-picker-value'); if (display && display.textContent !== text) display.textContent = text; picker.querySelectorAll('input[data-color-output]').forEach((out) => { if (out.value === text) return; out.value = text; out.dispatchEvent(new Event('change', { bubbles: true })); }); const switcher = picker.querySelector('select.color-picker-format'); if (switcher && switcher.value !== format) switcher.value = format;}/** * UI side of setState: 'default' optionally presets { value } through the * native color input (input event dispatched so the display stays in sync). */function triggerStateChange(picker, config) { if (typeof config?.format === 'string' && COLOR_FORMATS.includes(config.format)) { picker.dataset.format = config.format; syncValue(picker); } const input = getInput(picker); if (!input || config?.value === undefined) return; input.value = String(config.value); input.dispatchEvent(new Event('input', { bubbles: true }));}/** Registry-level API; pass the wrapper explicitly. Unknown names throw. */export const colorPickerApi = { setState(picker, stateName, config = {}) { if (!colorPickerStates.includes(stateName)) { throw new Error(`color-picker: unknown state "${stateName}" (supported: ${colorPickerStates.join(', ')})`); } triggerStateChange(picker, config); // state lives on the ELEMENT, not the module (many pickers per page) picker.dataset.stateName = stateName; picker._stateConfig = config; }, getState(picker) { const input = getInput(picker); return { name: picker.dataset.stateName || 'default', // live value - reflects picking and typing, not just setState // value: the native #rrggbb; formatted: the same colour in the // picker's notation (format) config: { ...picker._stateConfig, value: input ? input.value : '', format: formatOf(picker), formatted: input ? formatColor(input.value, formatOf(picker)) : '', }, }; },};df$.colorPickerApi = colorPickerApi;df$.colorPickerStates = colorPickerStates;function init() { document.querySelectorAll('.color-picker:not([data-init])').forEach((picker) => { picker.dataset.init = ''; // bind-scope the api per instance: `$('#theme-color').api.setState('default', { value: '#ff0000' })` picker.api = { setState: (stateName, config) => colorPickerApi.setState(picker, stateName, config), getState: () => colorPickerApi.getState(picker), }; const input = picker.querySelector('input[type="color"]'); if (!input) return; syncValue(picker); input.addEventListener('input', () => { syncValue(picker); }); // user-switchable notation: <select class="color-picker-format"> picker.querySelector('select.color-picker-format')?.addEventListener('change', (e) => { picker.dataset.format = e.target.value; syncValue(picker); }); // authors may flip data-format at runtime too new MutationObserver(() => syncValue(picker)).observe(picker, { attributes: true, attributeFilter: ['data-format'] });});}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub