Theme
Design your own
On this page (9)
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.

§API

Generated from color-picker.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type ColorPickerState = 'default' - setState(name, config) takes the config of the state it names.

StateDescription
default
The picker with its colour.
Config fieldTypeDescription
value?stringthe colour, #rrggbb (the native input's value)
format?'hex' | 'rgb' | 'hsl' | 'oklch'the notation the field shows
formatted?stringreported by getState(): the colour written in that notation

Every element

MemberDescription
el.api.setState<S extends ColorPickerState>(name: S, config?: ColorPickerStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?ColorPickerStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: ColorPickerState; config: ColorPickerStateConfigs[ColorPickerState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: ColorPickerState; config: ColorPickerStateConfigs[ColorPickerState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: ColorPickerState; config: ColorPickerStateConfigs[ColorPickerState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: ColorPickerState; config: ColorPickerStateConfigs[ColorPickerState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: ColorPickerState; config: ColorPickerStateConfigs[ColorPickerState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.colorPickerApi.setState<S extends ColorPickerState>(el: HTMLElement, name: S, config?: ColorPickerStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?ColorPickerStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.colorPickerApi.getState(el: HTMLElement): { name: ColorPickerState; config: ColorPickerStateConfigs[ColorPickerState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: ColorPickerState; config: ColorPickerStateConfigs[ColorPickerState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.colorPickerApi.render(state: { name: ColorPickerState; config: ColorPickerStateConfigs[ColorPickerState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: ColorPickerState; config: ColorPickerStateConfigs[ColorPickerState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.colorPickerApi.store(el: HTMLElement): Store<{ name: ColorPickerState; config: ColorPickerStateConfigs[ColorPickerState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: ColorPickerState; config: ColorPickerStateConfigs[ColorPickerState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.colorPickerApi.commit<S extends ColorPickerState>(el: HTMLElement, name: S, config?: ColorPickerStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?ColorPickerStateConfigs[S]its config
df$.shadcn.colorPickerStates: ColorPickerState[]The declared states, 'default' first: default.

§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, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const colorPickerStates = ['default'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state (getState() reports the colour in both notations). */
export interface ColorPickerStateConfigs {
  /** The picker with its colour. */
  default: {
    /** the colour, #rrggbb (the native input's value) */
    value?: string;
    /** the notation the field shows */
    format?: 'hex' | 'rgb' | 'hsl' | 'oklch';
    /** reported by getState(): the colour written in that notation */
    formatted?: string;
  };
}
const getInput = (picker) => dfDollar(picker).find<HTMLInputElement>('input[type="color"]').get(0);
/** 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 = dfDollar(picker).find('.color-picker-value').get(0);
  if (display && display.textContent !== text) display.textContent = text;
  dfDollar(picker).find<HTMLInputElement>('input[data-color-output]').toArray().forEach((out) => {
    if (out.value === text) return;
    out.value = text;
    out.dispatchEvent(new Event('change', { bubbles: true }));
  });
  const switcher = dfDollar(picker).find<HTMLSelectElement>('select.color-picker-format').get(0);
  if (switcher && switcher.value !== format) switcher.value = format;
}
/**
 * The markup of a state, for render(): the attributes a state writes, applied
 * to a detached copy of the authored markup ('default' IS the authored
 * markup). The live element gets the same markup from triggerStateChange -
 * the e2e render round trip proves they agree.
 */
function applyMarkup(el, config) {
  // the one state's markup is the value label: { format } (an attribute -
  // written only when it differs from the authored one) and { value } (the
  // input's property) feed the same syncValue() the live picker runs
  if (typeof config?.format === 'string' && COLOR_FORMATS.includes(config.format) && config.format !== formatOf(el)) dfDollar(el).attr('data-format', config.format);
  const input = getInput(el);
  if (input && typeof config?.value === 'string') input.value = config.value;
  syncValue(el);
}
/**
 * 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) {
  // only a format that differs is written: setState(getState()) changes nothing
  if (typeof config?.format === 'string' && COLOR_FORMATS.includes(config.format) && config.format !== formatOf(picker)) {
    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 = componentState({
  component: 'color-picker',
  states: colorPickerStates,
  apply: (picker, state) => triggerStateChange(picker, state.config),
  read: (picker, state) => {
    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: {
        ...state.config,
        value: input ? input.value : '',
        format: formatOf(picker),
        formatted: input ? formatColor(input.value, formatOf(picker)) : '',
      },
    };
  },
  markup: (el, state) => applyMarkup(el, state.config),
});
df$.colorPickerApi = colorPickerApi;
df$.colorPickerStates = colorPickerStates;
function init() {
  dfDollar('.color-picker:not([data-init])').toArray().forEach((picker) => {
  picker.dataset.init = '';
  // el.store + el.api (AGENTS.md "State through stores")
  bindComponent(picker, colorPickerApi);
  const input = dfDollar(picker).find<HTMLInputElement>('input[type="color"]').get(0);
  if (!input) return;
  syncValue(picker);
  input.addEventListener('input', () => { syncValue(picker); });
  // user-switchable notation: <select class="color-picker-format">
  dfDollar(picker).find('select.color-picker-format').get(0)?.addEventListener('change', (e) => {
    picker.dataset.format = (e.target as HTMLSelectElement).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