Tutorials

Building a Color Picker Web Component from Scratch

11 min read

The native <input type="color"> is a blunt instrument. It delegates to the OS color picker — which looks different on every platform, cannot be styled, and offers no programmatic control over layout. For design tools, accessibility checkers, or any product where the color picker is a central interaction, you need to build your own.

Web Components are the right architecture for a color picker. They are framework-agnostic, encapsulate their internal state with Shadow DOM, fire standard DOM events, and work in React, Vue, Angular, or plain HTML without adaptation. Build it once, use it everywhere.

This guide implements a complete color picker: a 2D color area canvas, a hue slider, an alpha channel slider, hex input, and full keyboard navigation with ARIA labels.


Web Component Fundamentals for UI

The Custom Element API

A Web Component is a class that extends HTMLElement. The browser registers it with a tag name, and it behaves like any built-in element — including support for attributes, properties, events, and slotted children:

class ColorPicker extends HTMLElement {
  // Called when element is first connected to the DOM
  connectedCallback() {}

  // Called when element is removed from the DOM
  disconnectedCallback() {}

  // Observed attributes trigger attributeChangedCallback
  static get observedAttributes() {
    return ['value', 'alpha'];
  }

  // Called when an observed attribute changes
  attributeChangedCallback(name, oldValue, newValue) {}
}

customElements.define('color-picker', ColorPicker);

Usage anywhere in HTML:

<color-picker value="#FF5733" alpha="true"></color-picker>

Or in React (with a ref for event handling):

import { useRef, useEffect } from 'react';

function App() {
  const pickerRef = useRef(null);

  useEffect(() => {
    const picker = pickerRef.current;
    const handler = (e) => console.log(e.detail.hex);
    picker.addEventListener('color-change', handler);
    return () => picker.removeEventListener('color-change', handler);
  }, []);

  return <color-picker ref={pickerRef} value="#FF5733" />;
}

Shadow DOM for Encapsulation

Shadow DOM gives the component a private DOM tree that is isolated from the main document. External CSS cannot accidentally restyle internal elements, and internal IDs do not conflict with page IDs:

class ColorPicker extends HTMLElement {
  constructor() {
    super();
    // Attach a shadow root — 'open' allows external JS access via element.shadowRoot
    this.shadow = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.shadow.innerHTML = `
      <style>
        :host {
          display: inline-block;
          width: 280px;
          font-family: system-ui, -apple-system, sans-serif;
        }
        .picker-area {
          position: relative;
          width: 100%;
          height: 200px;
          border-radius: 8px;
          overflow: hidden;
          cursor: crosshair;
        }
        canvas {
          display: block;
          width: 100%;
          height: 100%;
        }
      </style>
      <div class="picker-area">
        <canvas id="color-area" width="280" height="200"></canvas>
        <div class="picker-cursor" role="presentation"></div>
      </div>
      <div class="sliders">
        <div class="slider-row">
          <canvas id="hue-slider" width="280" height="20" role="slider"
                  aria-label="Hue" aria-valuemin="0" aria-valuemax="360"></canvas>
        </div>
      </div>
    `;
  }
}

Canvas-Based Color Area Rendering

The Color Area

The main color picker area is a 2D gradient: horizontal axis is saturation (0% to 100%), vertical axis is value/brightness (100% at top, 0% at bottom). The hue is fixed by the hue slider. This is the HSV color model — distinct from HSL, which maps lightness differently.

function drawColorArea(canvas, hue) {
  const ctx = canvas.getContext('2d');
  const { width, height } = canvas;

  // Draw the base hue as a horizontal white-to-hue gradient
  const hueGradient = ctx.createLinearGradient(0, 0, width, 0);
  hueGradient.addColorStop(0, 'white');
  hueGradient.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
  ctx.fillStyle = hueGradient;
  ctx.fillRect(0, 0, width, height);

  // Overlay a vertical transparent-to-black gradient
  const darkGradient = ctx.createLinearGradient(0, 0, 0, height);
  darkGradient.addColorStop(0, 'rgba(0, 0, 0, 0)');
  darkGradient.addColorStop(1, 'rgba(0, 0, 0, 1)');
  ctx.fillStyle = darkGradient;
  ctx.fillRect(0, 0, width, height);
}

The combination of a white-to-hue horizontal gradient and a transparent-to-black vertical gradient creates the complete HSV space for that hue. The top-left corner is white (S=0, V=100), the top-right is the pure hue (S=100, V=100), the bottom-right is black (any S, V=0), and the bottom-left is black too.

Reading Pixel Color from Canvas

When the user clicks or drags in the color area, read the pixel at that position with getImageData:

function getColorAtPosition(canvas, x, y) {
  const ctx = canvas.getContext('2d');
  const pixel = ctx.getImageData(
    Math.max(0, Math.min(x, canvas.width - 1)),
    Math.max(0, Math.min(y, canvas.height - 1)),
    1, 1
  ).data;

  return {
    r: pixel[0],
    g: pixel[1],
    b: pixel[2],
  };
}

Drawing the Cursor

The cursor overlaid on the canvas indicates the current selection point. Draw it as an absolutely positioned circle:

.picker-cursor {
  position: absolute;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 2px solid white;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
  transform: translate(-50%, -50%);
  pointer-events: none;
}

Position it by setting left and top as percentages of the canvas dimensions:

function updateCursorPosition(cursor, x, y, canvasWidth, canvasHeight) {
  cursor.style.left = `${(x / canvasWidth) * 100}%`;
  cursor.style.top = `${(y / canvasHeight) * 100}%`;
}

Hue and Alpha Slider Implementation

Hue Slider

The hue slider is a horizontal canvas filled with the full rainbow spectrum from 0° to 360°:

function drawHueSlider(canvas) {
  const ctx = canvas.getContext('2d');
  const { width, height } = canvas;

  const gradient = ctx.createLinearGradient(0, 0, width, 0);
  gradient.addColorStop(0,     'hsl(0,   100%, 50%)');
  gradient.addColorStop(0.167, 'hsl(60,  100%, 50%)');
  gradient.addColorStop(0.333, 'hsl(120, 100%, 50%)');
  gradient.addColorStop(0.5,   'hsl(180, 100%, 50%)');
  gradient.addColorStop(0.667, 'hsl(240, 100%, 50%)');
  gradient.addColorStop(0.833, 'hsl(300, 100%, 50%)');
  gradient.addColorStop(1,     'hsl(360, 100%, 50%)');

  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, width, height);
}

function hueFromX(x, canvasWidth) {
  return Math.round((x / canvasWidth) * 360);
}

function xFromHue(hue, canvasWidth) {
  return (hue / 360) * canvasWidth;
}

Alpha Slider

The alpha slider requires a checkerboard background (the standard indicator for transparency) beneath the color-to-transparent gradient:

function drawAlphaSlider(canvas, hex) {
  const ctx = canvas.getContext('2d');
  const { width, height } = canvas;

  // Checkerboard pattern via CSS background on the container,
  // or drawn manually for canvas-only:
  const size = 6;
  for (let row = 0; row < Math.ceil(height / size); row++) {
    for (let col = 0; col < Math.ceil(width / size); col++) {
      ctx.fillStyle = (row + col) % 2 === 0 ? '#CCCCCC' : '#FFFFFF';
      ctx.fillRect(col * size, row * size, size, size);
    }
  }

  // Overlay: color to transparent gradient
  const gradient = ctx.createLinearGradient(0, 0, width, 0);
  gradient.addColorStop(0, `${hex}00`); // transparent
  gradient.addColorStop(1, hex);        // opaque

  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, width, height);
}

Unified Slider Interaction

Both sliders share the same drag interaction pattern. Extract it into a reusable helper:

function makeDraggable(element, onMove) {
  let dragging = false;

  const getX = (e) => {
    const rect = element.getBoundingClientRect();
    const clientX = e.touches ? e.touches[0].clientX : e.clientX;
    return Math.max(0, Math.min(clientX - rect.left, rect.width));
  };

  element.addEventListener('mousedown', (e) => {
    dragging = true;
    onMove(getX(e));
  });
  element.addEventListener('touchstart', (e) => {
    dragging = true;
    onMove(getX(e));
  }, { passive: true });

  document.addEventListener('mousemove', (e) => {
    if (dragging) onMove(getX(e));
  });
  document.addEventListener('touchmove', (e) => {
    if (dragging) onMove(getX(e));
  }, { passive: true });

  document.addEventListener('mouseup', () => { dragging = false; });
  document.addEventListener('touchend', () => { dragging = false; });
}

The document-level mousemove and mouseup handlers ensure dragging continues even if the cursor moves outside the slider bounds — critical for usability.


Dispatching Color Change Events

Custom Event Pattern

Use CustomEvent with a detail payload containing the full color data. Name it with a namespace prefix to avoid conflicts:

function dispatchColorChange(element, color) {
  const event = new CustomEvent('color-change', {
    bubbles: true,        // Propagates up the DOM tree
    composed: true,       // Crosses Shadow DOM boundaries
    detail: {
      hex: color.hex,
      rgb: color.rgb,
      hsl: color.hsl,
      alpha: color.alpha,
      hexWithAlpha: color.alpha < 1
        ? `${color.hex}${Math.round(color.alpha * 255).toString(16).padStart(2, '0').toUpperCase()}`
        : color.hex,
    },
  });
  element.dispatchEvent(event);
}

composed: true is essential — without it, events dispatched inside Shadow DOM do not cross the boundary and are invisible to code in the main document.

Internal State Management

Maintain color state as HSV + alpha (the most natural model for a visual picker) and convert to other formats on output:

class ColorPicker extends HTMLElement {
  #state = {
    h: 0,    // Hue 0-360
    s: 100,  // Saturation 0-100 (HSV)
    v: 100,  // Value/Brightness 0-100 (HSV)
    a: 1,    // Alpha 0-1
  };

  get #hex() {
    const { r, g, b } = hsvToRgb(this.#state.h, this.#state.s, this.#state.v);
    return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('').toUpperCase()}`;
  }

  #update(changes) {
    this.#state = { ...this.#state, ...changes };
    this.#render();
    dispatchColorChange(this, {
      hex: this.#hex,
      rgb: hsvToRgb(this.#state.h, this.#state.s, this.#state.v),
      hsl: rgbToHsl(hsvToRgb(this.#state.h, this.#state.s, this.#state.v)),
      alpha: this.#state.a,
    });
  }
}

The #update method is called whenever any part of the color changes — dragging the hue slider, clicking the color area, or typing in the hex input. It updates state, re-renders affected canvases, and fires the event.

Hex Input Synchronization

The hex input should reflect the current color and also accept user edits:

setupHexInput(hexInput) {
  // Reflect state into input on every update
  this.addEventListener('_internal-state-change', () => {
    if (document.activeElement !== hexInput) {
      hexInput.value = this.#hex;
    }
  });

  // Parse input and update state when user types
  hexInput.addEventListener('input', (e) => {
    const raw = e.target.value.trim();
    const match = raw.match(/^#?([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$/);
    if (!match) return; // Silently ignore incomplete input

    const full = match[1].length === 3
      ? match[1].split('').map(c => c + c).join('')
      : match[1];

    const r = parseInt(full.slice(0, 2), 16);
    const g = parseInt(full.slice(2, 4), 16);
    const b = parseInt(full.slice(4, 6), 16);
    const { h, s, v } = rgbToHsv(r, g, b);
    this.#update({ h, s, v });
  });
}

Skipping the update when the input is focused prevents a feedback loop where typing #FF causes a state update that overwrites the hex input mid-type.


Accessibility: Keyboard Navigation, ARIA Labels, Screen Readers

The Accessibility Challenge

A canvas element is opaque to screen readers — getImageData returns pixel values, but those pixels convey no semantic meaning to assistive technology. Making a color picker accessible requires layering semantic information on top of the visual canvas.

ARIA Roles for Sliders

The color area, hue slider, and alpha slider all function as two-dimensional sliders. Use role="slider" with the full ARIA slider attribute set:

<!-- Color area: 2D, use two separate sliders for the axes -->
<div
  role="slider"
  id="color-area-saturation"
  aria-label="Color saturation"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuenow="75"
  aria-valuetext="75% saturation"
  tabindex="0"
></div>

<div
  role="slider"
  id="color-area-brightness"
  aria-label="Color brightness"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuenow="90"
  aria-valuetext="90% brightness"
  tabindex="0"
></div>

<!-- Hue slider -->
<div
  role="slider"
  id="hue-slider"
  aria-label="Hue"
  aria-valuemin="0"
  aria-valuemax="360"
  aria-valuenow="120"
  aria-valuetext="120 degrees, green"
  tabindex="0"
></div>

Update aria-valuenow and aria-valuetext in the #update method:

#updateAriaAttributes() {
  const hueEl = this.shadow.querySelector('#hue-slider');
  hueEl.setAttribute('aria-valuenow', this.#state.h);
  hueEl.setAttribute('aria-valuetext', `${this.#state.h} degrees`);

  const satEl = this.shadow.querySelector('#color-area-saturation');
  satEl.setAttribute('aria-valuenow', Math.round(this.#state.s));
  satEl.setAttribute('aria-valuetext', `${Math.round(this.#state.s)}% saturation`);
}

Keyboard Navigation

Each slider element must respond to arrow keys for keyboard users. The standard slider keyboard interaction is: - Arrow Right / Arrow Up: increase value by one unit (or 5 for coarser steps with Shift) - Arrow Left / Arrow Down: decrease value - Home: set to minimum - End: set to maximum

setupKeyboardNavigation(hueSlider) {
  hueSlider.addEventListener('keydown', (e) => {
    let delta = 0;
    const step = e.shiftKey ? 10 : 1;

    switch (e.key) {
      case 'ArrowRight':
      case 'ArrowUp':
        delta = step;
        break;
      case 'ArrowLeft':
      case 'ArrowDown':
        delta = -step;
        break;
      case 'Home':
        this.#update({ h: 0 });
        e.preventDefault();
        return;
      case 'End':
        this.#update({ h: 360 });
        e.preventDefault();
        return;
      default:
        return;
    }

    e.preventDefault();
    this.#update({ h: Math.max(0, Math.min(360, this.#state.h + delta)) });
  });
}

For the 2D color area, handle all four arrow keys to move in both dimensions:

setupColorAreaKeyboard(colorArea) {
  colorArea.addEventListener('keydown', (e) => {
    const step = e.shiftKey ? 5 : 1;
    let { s, v } = this.#state;

    switch (e.key) {
      case 'ArrowRight': s = Math.min(100, s + step); break;
      case 'ArrowLeft':  s = Math.max(0, s - step); break;
      case 'ArrowUp':    v = Math.min(100, v + step); break;
      case 'ArrowDown':  v = Math.max(0, v - step); break;
      default: return;
    }

    e.preventDefault();
    this.#update({ s, v });
  });
}

Live Region for Screen Reader Announcements

For a screen reader to announce the selected color when the user interacts with the picker, add an ARIA live region:

<div
  role="status"
  aria-live="polite"
  aria-atomic="true"
  class="sr-only"
  id="color-announcement"
></div>

Update it on every color change:

#announceColor() {
  const announcement = this.shadow.querySelector('#color-announcement');
  const { h, s, v, a } = this.#state;
  announcement.textContent = `Selected color: ${this.#hex}, ` +
    `hue ${h} degrees, saturation ${Math.round(s)}%, ` +
    `brightness ${Math.round(v)}%` +
    (a < 1 ? `, ${Math.round(a * 100)}% opacity` : '');
}

The aria-live="polite" setting announces changes when the user is not actively interacting with other elements. For a color picker where the user is likely dragging, polite is correct — assertive would interrupt every announcement with the next one.

Focus Indicators

Ensure focus rings are visible inside Shadow DOM. The :focus-visible pseudo-class provides keyboard-only focus rings without disturbing mouse interactions:

/* Inside the Shadow DOM <style> */
[role="slider"]:focus-visible {
  outline: 2px solid #2563EB;
  outline-offset: 2px;
}

.hex-input:focus-visible {
  outline: 2px solid #2563EB;
  outline-offset: 1px;
  border-radius: 4px;
}

Complete Accessible Component

Putting it all together, the component's connectedCallback sets up all interactive elements with their ARIA roles and keyboard handlers before any visual interaction occurs:

connectedCallback() {
  this.shadow.innerHTML = this.#template();
  this.#setupColorArea();
  this.#setupHueSlider();
  this.#setupAlphaSlider();
  this.#setupHexInput();
  this.#setupKeyboardNavigation();
  this.#render();
}

#template() {
  return `
    <style>${this.#css()}</style>
    <div class="color-picker" role="group" aria-label="Color picker">

      <!-- 2D Color Area -->
      <div class="picker-area-wrapper">
        <canvas id="color-area" width="280" height="200"
          aria-hidden="true"></canvas>
        <div class="cursor" role="presentation"></div>
        <div id="color-area-interactive"
          role="slider"
          aria-label="Color saturation and brightness"
          aria-valuemin="0" aria-valuemax="100"
          aria-valuenow="100" aria-valuetext="full saturation, full brightness"
          tabindex="0"
          class="interactive-overlay">
        </div>
      </div>

      <!-- Hue Slider -->
      <div class="slider-wrapper">
        <canvas id="hue-canvas" width="280" height="20" aria-hidden="true"></canvas>
        <div id="hue-thumb" class="slider-thumb" role="slider"
          aria-label="Hue" aria-valuemin="0" aria-valuemax="360"
          aria-valuenow="0" aria-valuetext="0 degrees, red"
          tabindex="0"></div>
      </div>

      <!-- Alpha Slider -->
      <div class="slider-wrapper" id="alpha-wrapper">
        <canvas id="alpha-canvas" width="280" height="20" aria-hidden="true"></canvas>
        <div id="alpha-thumb" class="slider-thumb" role="slider"
          aria-label="Opacity" aria-valuemin="0" aria-valuemax="100"
          aria-valuenow="100" aria-valuetext="100% opacity"
          tabindex="0"></div>
      </div>

      <!-- Hex Input -->
      <div class="hex-row">
        <label for="hex-input" class="hex-label">HEX</label>
        <input id="hex-input" type="text" class="hex-input"
          value="#FF5733" maxlength="7"
          aria-label="Hex color code"
          autocomplete="off" spellcheck="false" />
      </div>

      <!-- Screen reader live region -->
      <div role="status" aria-live="polite" aria-atomic="true"
        id="color-announcement" class="sr-only"></div>
    </div>
  `;
}

The aria-hidden="true" on the canvases tells screen readers to ignore the pixel content entirely — the semantic meaning is provided by the interactive overlay elements beside them.


Publishing and Using the Component

Publishing to npm

A Web Component is just a JavaScript module. Publish it with a minimal package.json:

{
  "name": "@yourscope/color-picker",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/color-picker.js",
  "exports": {
    ".": "./dist/color-picker.js"
  },
  "files": ["dist/"]
}

Import in any project:

import '@yourscope/color-picker';

// Now <color-picker> is registered globally

Using with the ColorFYI Converter

Pair the color picker with the Color Converter API to show full color data alongside the visual picker:

document.querySelector('color-picker').addEventListener('color-change', async (e) => {
  const hex = e.detail.hex.replace('#', '');
  const response = await fetch(`/api/color/${hex}`);
  const data = await response.json();

  document.querySelector('#hex-display').textContent = data.hex;
  document.querySelector('#rgb-display').textContent = data.rgb.css;
  document.querySelector('#hsl-display').textContent = data.hsl.css;
  document.querySelector('#oklch-display').textContent = data.oklch.css;
});

And use the Palette Generator to show harmonious colors for any selected value — building a complete color exploration experience around the picker.


Key Takeaways

  • Web Components with Shadow DOM are framework-agnostic, style-encapsulated, and fire standard DOM events — the right architecture for reusable UI like color pickers.
  • The color area uses two overlaid canvas gradients: horizontal white-to-hue for saturation, vertical transparent-to-black for value/brightness. getImageData reads the pixel color at any drag position.
  • Hue and alpha sliders use the same draggable canvas pattern; the key is attaching mousemove and mouseup to document, not the element, so dragging works even when the cursor leaves the slider.
  • Dispatch CustomEvent with composed: true so events cross Shadow DOM boundaries and are visible to parent document code.
  • Canvas elements are opaque to screen readers — provide semantic meaning through role="slider" overlay elements with aria-valuenow and aria-valuetext updated on every state change.
  • Implement full arrow key navigation on each slider: single-step on arrow keys, five-step with Shift, Home/End for min/max.
  • Add an aria-live="polite" status region that announces the full color description (hex, hue, saturation, brightness, opacity) on each change.
  • Combine with the Color Converter API to display full format breakdowns, and the Palette Generator to show harmonious colors alongside any picked value.

Related Colors

Related Brands

Related Tools