Skip to main content
Back to Blog
Tutorials
4 min read
December 10, 2024

How to Build a Color Picker Component in React

Build an accessible color picker component with HSL gradient, hex input, preset swatches, and clipboard copy in React.

Ryel Banfield

Founder & Lead Developer

A color picker is useful in design tools, theme builders, and content editors. Here is how to build one from scratch with HSL manipulation.

Color Conversion Utilities

// lib/color-utils.ts
export interface HSL {
  h: number; // 0-360
  s: number; // 0-100
  l: number; // 0-100
}

export function hslToHex({ h, s, l }: HSL): string {
  const sNorm = s / 100;
  const lNorm = l / 100;
  const a = sNorm * Math.min(lNorm, 1 - lNorm);

  function f(n: number): string {
    const k = (n + h / 30) % 12;
    const color = lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
    return Math.round(255 * color)
      .toString(16)
      .padStart(2, "0");
  }

  return `#${f(0)}${f(8)}${f(4)}`;
}

export function hexToHsl(hex: string): HSL {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  if (!result) return { h: 0, s: 0, l: 0 };

  const r = parseInt(result[1], 16) / 255;
  const g = parseInt(result[2], 16) / 255;
  const b = parseInt(result[3], 16) / 255;

  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  const l = (max + min) / 2;

  if (max === min) {
    return { h: 0, s: 0, l: Math.round(l * 100) };
  }

  const d = max - min;
  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);

  let h: number;
  switch (max) {
    case r:
      h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
      break;
    case g:
      h = ((b - r) / d + 2) / 6;
      break;
    default:
      h = ((r - g) / d + 4) / 6;
  }

  return {
    h: Math.round(h * 360),
    s: Math.round(s * 100),
    l: Math.round(l * 100),
  };
}

export function isValidHex(hex: string): boolean {
  return /^#?([a-f\d]{3}|[a-f\d]{6})$/i.test(hex);
}

The Color Picker Component

"use client";

import { useCallback, useRef, useState } from "react";
import { hslToHex, hexToHsl, isValidHex, type HSL } from "@/lib/color-utils";

interface ColorPickerProps {
  value?: string;
  onChange?: (hex: string) => void;
  presets?: string[];
}

const DEFAULT_PRESETS = [
  "#ef4444", "#f97316", "#eab308", "#22c55e",
  "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899",
  "#000000", "#6b7280", "#ffffff",
];

export function ColorPicker({
  value = "#3b82f6",
  onChange,
  presets = DEFAULT_PRESETS,
}: ColorPickerProps) {
  const [hsl, setHsl] = useState<HSL>(hexToHsl(value));
  const [hexInput, setHexInput] = useState(value);
  const [copied, setCopied] = useState(false);

  const hex = hslToHex(hsl);

  const updateColor = useCallback(
    (newHsl: HSL) => {
      setHsl(newHsl);
      const newHex = hslToHex(newHsl);
      setHexInput(newHex);
      onChange?.(newHex);
    },
    [onChange]
  );

  const handleHexChange = useCallback(
    (input: string) => {
      setHexInput(input);
      const normalized = input.startsWith("#") ? input : `#${input}`;
      if (isValidHex(normalized)) {
        const newHsl = hexToHsl(normalized);
        setHsl(newHsl);
        onChange?.(normalized);
      }
    },
    [onChange]
  );

  const copyToClipboard = useCallback(async () => {
    await navigator.clipboard.writeText(hex);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  }, [hex]);

  return (
    <div className="w-72 p-4 border rounded-lg bg-background shadow-lg space-y-4">
      {/* Saturation/Lightness Gradient */}
      <SaturationPicker hsl={hsl} onChange={updateColor} />

      {/* Hue Slider */}
      <div>
        <label className="text-xs text-muted-foreground">Hue</label>
        <input
          type="range"
          min={0}
          max={360}
          value={hsl.h}
          onChange={(e) => updateColor({ ...hsl, h: Number(e.target.value) })}
          className="w-full h-3 rounded-full appearance-none cursor-pointer"
          style={{
            background:
              "linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)",
          }}
        />
      </div>

      {/* Lightness Slider */}
      <div>
        <label className="text-xs text-muted-foreground">Lightness</label>
        <input
          type="range"
          min={0}
          max={100}
          value={hsl.l}
          onChange={(e) => updateColor({ ...hsl, l: Number(e.target.value) })}
          className="w-full h-3 rounded-full appearance-none cursor-pointer"
          style={{
            background: `linear-gradient(to right, #000, hsl(${hsl.h}, ${hsl.s}%, 50%), #fff)`,
          }}
        />
      </div>

      {/* Hex Input and Preview */}
      <div className="flex items-center gap-2">
        <div
          className="h-10 w-10 rounded-md border shrink-0"
          style={{ backgroundColor: hex }}
        />
        <div className="flex-1 relative">
          <input
            type="text"
            value={hexInput}
            onChange={(e) => handleHexChange(e.target.value)}
            maxLength={7}
            className="w-full px-3 py-2 border rounded-md text-sm font-mono"
            aria-label="Hex color value"
          />
        </div>
        <button
          onClick={copyToClipboard}
          className="px-3 py-2 border rounded-md text-sm hover:bg-muted"
          aria-label="Copy color value"
        >
          {copied ? "Done" : "Copy"}
        </button>
      </div>

      {/* HSL Values */}
      <div className="grid grid-cols-3 gap-2">
        <div>
          <label className="text-xs text-muted-foreground">H</label>
          <input
            type="number"
            min={0}
            max={360}
            value={hsl.h}
            onChange={(e) => updateColor({ ...hsl, h: Number(e.target.value) })}
            className="w-full px-2 py-1 border rounded text-sm text-center"
          />
        </div>
        <div>
          <label className="text-xs text-muted-foreground">S</label>
          <input
            type="number"
            min={0}
            max={100}
            value={hsl.s}
            onChange={(e) => updateColor({ ...hsl, s: Number(e.target.value) })}
            className="w-full px-2 py-1 border rounded text-sm text-center"
          />
        </div>
        <div>
          <label className="text-xs text-muted-foreground">L</label>
          <input
            type="number"
            min={0}
            max={100}
            value={hsl.l}
            onChange={(e) => updateColor({ ...hsl, l: Number(e.target.value) })}
            className="w-full px-2 py-1 border rounded text-sm text-center"
          />
        </div>
      </div>

      {/* Presets */}
      {presets.length > 0 && (
        <div>
          <label className="text-xs text-muted-foreground">Presets</label>
          <div className="flex flex-wrap gap-1.5 mt-1">
            {presets.map((preset) => (
              <button
                key={preset}
                onClick={() => {
                  setHexInput(preset);
                  const newHsl = hexToHsl(preset);
                  updateColor(newHsl);
                }}
                className={`h-6 w-6 rounded-md border-2 ${
                  hex.toLowerCase() === preset.toLowerCase()
                    ? "border-primary ring-2 ring-primary/30"
                    : "border-transparent hover:border-muted-foreground/30"
                }`}
                style={{ backgroundColor: preset }}
                aria-label={`Select color ${preset}`}
              />
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

Saturation Picker Canvas

function SaturationPicker({
  hsl,
  onChange,
}: {
  hsl: HSL;
  onChange: (hsl: HSL) => void;
}) {
  const canvasRef = useRef<HTMLDivElement>(null);
  const isDragging = useRef(false);

  const handlePointerDown = useCallback(
    (e: React.PointerEvent) => {
      isDragging.current = true;
      (e.target as HTMLElement).setPointerCapture(e.pointerId);
      updateFromPointer(e);
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [hsl.h, onChange]
  );

  const handlePointerMove = useCallback(
    (e: React.PointerEvent) => {
      if (!isDragging.current) return;
      updateFromPointer(e);
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [hsl.h, onChange]
  );

  const handlePointerUp = useCallback(() => {
    isDragging.current = false;
  }, []);

  function updateFromPointer(e: React.PointerEvent) {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return;

    const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
    const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height));

    const s = Math.round((x / rect.width) * 100);
    const l = Math.round(100 - (y / rect.height) * 100);

    onChange({ ...hsl, s, l });
  }

  // Position of the selector circle
  const selectorX = `${hsl.s}%`;
  const selectorY = `${100 - hsl.l}%`;

  return (
    <div
      ref={canvasRef}
      className="relative h-40 w-full rounded-md cursor-crosshair touch-none"
      style={{
        background: `
          linear-gradient(to top, #000, transparent),
          linear-gradient(to right, #fff, hsl(${hsl.h}, 100%, 50%))
        `,
      }}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={handlePointerUp}
    >
      <div
        className="absolute h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-md pointer-events-none"
        style={{
          left: selectorX,
          top: selectorY,
          backgroundColor: hslToHex(hsl),
        }}
      />
    </div>
  );
}

Usage

"use client";

import { useState } from "react";
import { ColorPicker } from "@/components/ColorPicker";

export default function ThemeBuilder() {
  const [primaryColor, setPrimaryColor] = useState("#3b82f6");

  return (
    <div>
      <h2 className="text-lg font-semibold mb-4">Choose Primary Color</h2>
      <ColorPicker value={primaryColor} onChange={setPrimaryColor} />
      <div
        className="mt-4 px-6 py-3 rounded-md text-white text-center"
        style={{ backgroundColor: primaryColor }}
      >
        Preview Button
      </div>
    </div>
  );
}

Need Custom UI Components?

We design and build polished interactive components for web applications. Contact us to create a custom component library.

color pickerHSLReactcomponenttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles