Build an Accessible React Color Picker: A Practical Guide

Build a fast, accessible React color picker from scratch: HSV logic, sliders, popover, keyboard/mouse support, CSS variables, tests, and library options.

ASOasis
11 min read
Build an Accessible React Color Picker: A Practical Guide

Image used for representation purposes only.

Overview

A good color picker is one of those components that looks simple but hides a lot of nuance: color models, pointer math, accessibility, keyboard support, alpha transparency, controlled state, and performance. In this tutorial you’ll build a fast, accessible React color picker from first principles. You’ll learn the HSV model, write conversion utilities, create a saturation/value panel plus hue and alpha sliders, wire up keyboard and pointer interactions, and expose a clean API for app code.

By the end you’ll have a reusable that returns HEX and RGBA, works with both mouse and touch, and provides sensible accessibility and testing hooks.

Prerequisites

  • React 18+
  • Familiarity with hooks and basic CSS
  • A build setup (Vite, Next.js, or CRA). Examples below assume Vite and plain JavaScript, but TypeScript notes are included.

What we’re building

Features:

  • Saturation/Value (SV) 2D panel with a draggable thumb
  • Hue slider (0–360°)
  • Alpha slider with checkerboard background
  • HEX and RGBA input fields
  • Keyboard support: arrows, PageUp/PageDown, Home/End
  • Controlled/uncontrolled API

Color models 101 (HEX, RGB, HSV)

  • RGB represents color by red/green/blue channels in 0–255.
  • HEX encodes RGB as a hexadecimal string, commonly used in CSS.
  • HSV (Hue, Saturation, Value) separates color (hue) from intensity (value) and purity (saturation). It maps nicely to our UI: one 2D plane for S and V, one 1D slider for Hue.

We’ll keep state in HSVA: { h: 0–360, s: 0–100, v: 0–100, a: 0–1 } and convert to/from RGB/HEX as needed.

Conversion utilities

Create src/color-utils.js:

// src/color-utils.js
export const clamp = (n, min, max) => Math.min(max, Math.max(min, n));

export function hexToRgb(hex) {
  let h = hex.replace(/^#/, '');
  if (h.length === 3) h = h.split('').map(c => c + c).join('');
  const num = parseInt(h, 16);
  return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255, a: 1 };
}

export function rgbToHex({ r, g, b }) {
  const toHex = x => x.toString(16).padStart(2, '0');
  return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}

export function hsvToRgb({ h, s, v, a = 1 }) {
  s /= 100; v /= 100;
  const c = v * s;
  const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
  const m = v - c;
  let [r1, g1, b1] = [0, 0, 0];
  if (h >= 0 && h < 60) [r1, g1, b1] = [c, x, 0];
  else if (h < 120) [r1, g1, b1] = [x, c, 0];
  else if (h < 180) [r1, g1, b1] = [0, c, x];
  else if (h < 240) [r1, g1, b1] = [0, x, c];
  else if (h < 300) [r1, g1, b1] = [x, 0, c];
  else [r1, g1, b1] = [c, 0, x];
  return {
    r: Math.round((r1 + m) * 255),
    g: Math.round((g1 + m) * 255),
    b: Math.round((b1 + m) * 255),
    a,
  };
}

export function rgbToHsv({ r, g, b, a = 1 }) {
  let rn = r / 255, gn = g / 255, bn = b / 255;
  const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
  const d = max - min;
  let h = 0;
  if (d !== 0) {
    if (max === rn) h = ((gn - bn) / d) % 6;
    else if (max === gn) h = (bn - rn) / d + 2;
    else h = (rn - gn) / d + 4;
    h *= 60; if (h < 0) h += 360;
  }
  const s = max === 0 ? 0 : d / max;
  const v = max;
  return { h: Math.round(h), s: Math.round(s * 100), v: Math.round(v * 100), a };
}

Tip: Write unit tests for these functions first; if conversions are wrong, the UI won’t make sense.

Base styles

Add a few reusable CSS variables and utilities in src/color-picker.css:

/* src/color-picker.css */
:root { --cp-size: 220px; --cp-radius: 10px; --cp-gap: 10px; --cp-thumb: 12px; }

.cp { display: grid; grid-template-columns: var(--cp-size) 1fr; gap: var(--cp-gap); align-items: start; font: 14px/1.3 system-ui, sans-serif; }

/* SV panel */
.cp-sv { position: relative; width: var(--cp-size); height: var(--cp-size); border-radius: var(--cp-radius); overflow: hidden; touch-action: none; cursor: crosshair; }
.cp-sv::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to top, #000, rgba(0,0,0,0)); }
.cp-sv::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to right, #fff, rgba(255,255,255,0)); }
.cp-sv-thumb { position: absolute; width: var(--cp-thumb); height: var(--cp-thumb); border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.5); border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; }

/* Sliders */
.cp-slider { position: relative; height: 14px; border-radius: 999px; overflow: hidden; touch-action: none; cursor: ew-resize; }
.cp-slider-track { position: absolute; inset: 0; }
.cp-slider-thumb { position: absolute; top: 50%; width: var(--cp-thumb); height: var(--cp-thumb); border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.5); border-radius: 50%; transform: translate(-50%, -50%); background: currentColor; }

/* Alpha checkerboard */
.cp-checker { background:
  conic-gradient(#eee 25%, #ddd 0 50%, #eee 0 75%, #ddd 0) 0 0/16px 16px;
}

/* Inputs */
.cp-fields { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; align-items: center; }
.cp-fields input { width: 100%; padding: 6px 8px; border-radius: 6px; border: 1px solid #d0d0d0; }

/* Swatch & preview */
.cp-preview { width: 44px; height: 44px; border-radius: 8px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.1); }

Building blocks

We’ll create three interactive parts and a wrapper that manages HSVA state.

A. Saturation/Value panel (2D)

// src/SaturationValue.jsx
import * as React from 'react';
import { clamp } from './color-utils.js';

export function SaturationValue({ h, s, v, onChange }) {
  const ref = React.useRef(null);

  const setByPointer = (clientX, clientY) => {
    const rect = ref.current.getBoundingClientRect();
    const x = clamp(clientX - rect.left, 0, rect.width);
    const y = clamp(clientY - rect.top, 0, rect.height);
    const ns = Math.round((x / rect.width) * 100);
    const nv = Math.round(100 - (y / rect.height) * 100);
    onChange({ s: ns, v: nv });
  };

  const onPointerDown = e => {
    e.preventDefault();
    ref.current.setPointerCapture(e.pointerId);
    setByPointer(e.clientX, e.clientY);
  };
  const onPointerMove = e => {
    if (e.buttons !== 1) return;
    setByPointer(e.clientX, e.clientY);
  };

  const left = `${s}%`; // 0..100
  const top = `${100 - v}%`;
  const bg = `hsl(${h} 100% 50%)`;

  return (
    <div
      ref={ref}
      className='cp-sv'
      style={{ backgroundColor: bg }}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      role='application'
      aria-label='Saturation and value'
    >
      <div className='cp-sv-thumb' style={{ left, top }} />
    </div>
  );
}

Accessibility note: The 2D area doesn’t map neatly to a native ARIA role. We expose text inputs and sliders that are fully keyboard-accessible, and label the panel as an application region.

B. Generic horizontal slider

// src/Slider.jsx
import * as React from 'react';
import { clamp } from './color-utils.js';

export function Slider({ value, min = 0, max = 100, step = 1, onChange, gradient, ariaLabel }) {
  const ref = React.useRef(null);
  const pct = (value - min) / (max - min);

  const setFromEvent = (clientX) => {
    const rect = ref.current.getBoundingClientRect();
    const x = clamp(clientX - rect.left, 0, rect.width);
    const ratio = x / rect.width;
    const raw = min + ratio * (max - min);
    const snapped = Math.round(raw / step) * step;
    onChange(clamp(snapped, min, max));
  };

  const onPointerDown = e => {
    e.preventDefault();
    ref.current.setPointerCapture(e.pointerId);
    setFromEvent(e.clientX);
  };
  const onPointerMove = e => { if (e.buttons === 1) setFromEvent(e.clientX); };

  const onKeyDown = e => {
    const key = e.key;
    const delta =
      key === 'ArrowRight' ? step :
      key === 'ArrowLeft' ? -step :
      key === 'PageUp' ? step * 10 :
      key === 'PageDown' ? -step * 10 :
      key === 'Home' ? (min - value) :
      key === 'End' ? (max - value) : 0;
    if (delta !== 0) { e.preventDefault(); onChange(clamp(value + delta, min, max)); }
  };

  return (
    <div
      ref={ref}
      className='cp-slider'
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      role='slider'
      aria-label={ariaLabel}
      aria-valuemin={min}
      aria-valuemax={max}
      aria-valuenow={Math.round(value)}
      tabIndex={0}
    >
      <div className='cp-slider-track' style={{ background: gradient }} />
      <div className='cp-slider-thumb' style={{ left: `${pct * 100}%` }} />
    </div>
  );
}

C. The ColorPicker wrapper

// src/ColorPicker.jsx
import * as React from 'react';
import './color-picker.css';
import { SaturationValue } from './SaturationValue.jsx';
import { Slider } from './Slider.jsx';
import { hexToRgb, rgbToHex, hsvToRgb, rgbToHsv, clamp } from './color-utils.js';

export function ColorPicker({ color = '#4f46e5', onChange }) {
  const [hsva, setHsva] = React.useState(() => rgbToHsv(hexToRgb(color)));

  // Sync external color prop
  React.useEffect(() => { setHsva(rgbToHsv(hexToRgb(color))); }, [color]);

  // Emit value changes upward
  React.useEffect(() => {
    const rgba = hsvToRgb(hsva);
    const hex = rgbToHex(rgba);
    onChange?.({ hsva, rgba, hex });
  }, [hsva, onChange]);

  const { h, s, v, a } = hsva;
  const rgba = hsvToRgb(hsva);
  const cssRgba = `rgba(${rgba.r} ${rgba.g} ${rgba.b} / ${a})`;

  const hueGradient = 'linear-gradient(to right, #f00 0%, #ff0 16.7%, #0f0 33.3%, #0ff 50%, #00f 66.7%, #f0f 83.3%, #f00 100%)';
  const alphaGradient = `linear-gradient(to right, rgba(${rgba.r},${rgba.g},${rgba.b},0), rgba(${rgba.r},${rgba.g},${rgba.b},1))`;

  return (
    <div className='cp'>
      <div>
        <SaturationValue
          h={h}
          s={s}
          v={v}
          onChange={({ s: ns, v: nv }) => setHsva(p => ({ ...p, s: ns, v: nv }))}
        />
        <div style={{ height: 12 }} />
        <Slider
          ariaLabel='Hue'
          value={h}
          min={0}
          max={360}
          step={1}
          onChange={nh => setHsva(p => ({ ...p, h: nh }))}
          gradient={hueGradient}
        />
        <div style={{ height: 8 }} />
        <div className='cp-checker' style={{ borderRadius: 9999 }}>
          <Slider
            ariaLabel='Alpha'
            value={Math.round(a * 100)}
            min={0}
            max={100}
            step={1}
            onChange={na => setHsva(p => ({ ...p, a: clamp(na / 100, 0, 1) }))}
            gradient={alphaGradient}
          />
        </div>
      </div>

      <div style={{ display: 'grid', gap: 10 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <div className='cp-checker cp-preview' style={{ background: cssRgba }} />
          <strong>{rgbToHex(rgba)}</strong>
        </div>

        <div className='cp-fields'>
          <label htmlFor='hex'>HEX</label>
          <input id='hex' value={rgbToHex(rgba)} onChange={e => {
            try {
              const rgb = hexToRgb(e.target.value.trim());
              setHsva(p => ({ ...rgbToHsv(rgb), a: p.a }));
            } catch {}
          }} />

          <label htmlFor='a'>A</label>
          <input id='a' type='number' min='0' max='1' step='0.01' value={a}
            onChange={e => setHsva(p => ({ ...p, a: clamp(parseFloat(e.target.value) || 0, 0, 1) }))} />

          <label htmlFor='r'>R</label>
          <input id='r' type='number' min='0' max='255' value={rgba.r}
            onChange={e => setHsva(p => rgbToHsv({ r: clamp(+e.target.value||0,0,255), g: rgba.g, b: rgba.b, a: p.a }))} />

          <label htmlFor='g'>G</label>
          <input id='g' type='number' min='0' max='255' value={rgba.g}
            onChange={e => setHsva(p => rgbToHsv({ r: rgba.r, g: clamp(+e.target.value||0,0,255), b: rgba.b, a: p.a }))} />

          <label htmlFor='b'>B</label>
          <input id='b' type='number' min='0' max='255' value={rgba.b}
            onChange={e => setHsva(p => rgbToHsv({ r: rgba.r, g: rgba.g, b: clamp(+e.target.value||0,0,255), a: p.a }))} />
        </div>
      </div>
    </div>
  );
}

Usage example:

// src/App.jsx
import * as React from 'react';
import { ColorPicker } from './ColorPicker.jsx';

export default function App() {
  const [color, setColor] = React.useState('#ff4d4f');
  return (
    <div style={{ padding: 24 }}>
      <h2>Theme color</h2>
      <ColorPicker color={color} onChange={({ hex }) => setColor(hex)} />
      <div style={{ marginTop: 24, color }}>This text reflects your color.</div>
    </div>
  );
}

Controlled vs. uncontrolled

The component above is semi-controlled:

  • Incoming color prop is parsed into HSVA.
  • Any internal change triggers onChange with hsva, rgba, and hex so parent state can sync.

For a fully controlled picker, keep HSVA state in the parent and pass it down, or accept a CSS color string and always derive HSVA locally as shown.

Keyboard and pointer interactions

  • Sliders implement onPointerDown/onPointerMove and keyboard handlers for ArrowLeft/Right, PageUp/PageDown, Home/End.
  • The SV panel uses pointer events; for keyboard accessibility, users can adjust precise values through the numeric inputs and hue/alpha sliders. Consider adding optional S and V range inputs for strictly keyboard-only flows.

Accessibility checklist

  • Role and labeling: sliders have role=‘slider’ with aria-valuemin/max/now and aria-label.
  • Focus: thumbs are not focusable; the whole slider track is, which simplifies keyboard control.
  • Contrast: thumbs use white borders plus subtle outer strokes to remain visible over any background.
  • Inputs: provide HEX and RGBA fields for screen readers and fine-grained control.

Performance tips

  • Keep HSVA as a single object to avoid cascading conversions.
  • Derive gradients from memoized rgba where possible.
  • Avoid re-renders by lifting expensive computations into useMemo if you later add more features.

Styling the checkerboard and gradients

  • The alpha slider uses a conic-based checkerboard, which is crisp and GPU-friendly.
  • The SV panel uses layered gradients over an hsl(h 100% 50%) base. This gives clean white and black falloffs without images.

Packaging and API surface

If you plan to reuse the picker across projects, consider:

  • Props: value (string or hsva), defaultValue, onChange, disabled, readOnly, className, style.
  • Event contract: onChange({ hsva, rgba, hex }).
  • i18n: number format and labels.
  • Theming: expose CSS variables for sizes and radii.

Testing: what to cover

  • Unit tests for hex/rgb/hsv conversions (edge values: 0, 255, black, white, gray).
  • Keyboard e2e: arrows move hue by 1°, page keys by 10°.
  • Pointer e2e: dragging across SV panel hits expected S/V near edges.
  • Accessibility: sliders have correct roles and value attributes; inputs have labels.

When to use a library

If you need advanced features quickly—like color history, swatches, or complex layouts—use a focused library. Popular choices include lightweight pickers that expose HSV and controlled props, offer excellent keyboard support, and ship tree-shakable ESM bundles. For most apps, the DIY version above is enough and remains easy to maintain.

Extensions and ideas

  • Add a vertical orientation prop for compact sidebars.
  • Add a popover trigger: a swatch button that toggles the picker in a floating layer.
  • Persist recent colors in localStorage.
  • Expose onChangeStart/onChangeEnd for expensive work (e.g., image filters) and throttle pointer moves.
  • Add a color contrast checker against a chosen background.

Summary

You built a production-ready React color picker that:

  • Stores color in HSVA and converts to RGB/HEX
  • Implements a draggable SV panel plus hue and alpha sliders
  • Supports mouse, touch, and keyboard
  • Surfaces accessible inputs and ARIA attributes
  • Exposes a clean onChange API and is easy to extend

Drop it into your design system, wire onChange to CSS variables, and you’ve got a powerful foundation for theming, visual editors, and more.

Related Posts