Theme
On this page (8)
Component Skill — components/color-picker/component-skill.md

Native basis

<input type="color"> element with label.

Web Platform APIs

<input type="color">

Classes

.color-picker.color-picker-value.color-picker-format

Notation (data-format)

hex#6366f1 - the defaultrgbrgb(99 102 241)hslhsl(238.7 83.5% 66.7%)oklchoklch(0.5854 0.2041 277.12) - the system tokens' notation

Notes

• The native color picker renders a full-featured dialog - no JS needed.

• The wrapper adds a styled border and shows the value in the notation data-format asks for; input[data-color-output] submits it that way.

• The color swatch is provided by the browser's native <input type="color">.

§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.value reports 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:

StateTypeValuesDefaultDescription
valuestring—"#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