DEV Community

Cover image for Building a simple Colour Picker in React from scratch
Jeremy
Jeremy

Posted on

Building a simple Colour Picker in React from scratch

While working on reducing FormBlob's dependencies and browser package size, I wrote a lightweight version of a colour picker to replace react-color. I've published it as the open source library react-mui-color, though that has a dependency on Material UI.

This tutorial takes you through how to create a colour picker from scratch with no dependencies, similar to what you see below. The full code can be found here. Don't be daunted if you're not familiar with Typescript, what you'd find here is absolutely understandable if you only know javascript.

Key Features

The colour picker we're about to build will have two different selection options:

  1. a predefined colour palette and
  2. a continuous colour map

Users may set colours using the selectors or key in colours in hex or rgb using inputs.

Planning the Component

Based on the functional requirements, our colour picker will need 4 props:

  1. color - the current color selected
  2. colors - the array of predefined colours for the colour palette
  3. onChange - the handler when a new colour is selected
  4. variant - the type of selector, predefined or free
// ColorPicker.tsx

export enum ColorPickerVariant {
  Predefined = "predefined",
  Free = "free"
}

interface ColorPickerProps {
  color: string;
  colors: Array<string>;
  onChange(color: string): void;
  variant: ColorPickerVariant;
}

export const ColorPicker = (props: ColorPickerProps) => {
  const { color, colors, onChange, variant } = props;

  ...
}
Enter fullscreen mode Exit fullscreen mode

We should also have a component for each selector to make the overall ColorPicker component more manageable and potentially more extensible if we'd like to add more selectors. Our predefined selector is fairly straightforward - we need the color, colors and onChange props defined above to populate the component and to handle any colour selections made by the user.

// PredefinedSelector.tsx

interface PredefinedSelectorProps {
  color: string;
  colors: Array<string>;
  onSelect(color: string): void;
}

export const PredefinedSelector = (props: PredefinedSelectorProps) => {
  const { color, colors, onSelect } = props;

  ...
}
Enter fullscreen mode Exit fullscreen mode

Our colour map selector (we'll call it the Free selector from now on) is more challenging. We need to find a way to render the colour map and convert selections on the map into a colour representation that CSS understands. Fortunately the HSV colour model maps well to a 3D linear gradient, but more on that later. For now, we know that we have two different maps - a larger saturation map and a linear hue map and we need to handle the click event for each map.

// FreeSelector.tsx

interface FreeSelectorProps {
  color: string; // we'll need to convert this to HSV
  satCoords: Array<number>; // [x, y] coordinates for saturation map
  hueCoords: number; // x coordinates for hue map
  onSaturationChange: MouseEventHandler;
  onHueChange: MouseEventHandler;
}

export const FreeSelector = (props: FreeSelectorProps) => {
  const {
    color,
    satCoords,
    hueCoords,
    onSaturationChange,
    onHueChange
  } = props;

  ...
}
Enter fullscreen mode Exit fullscreen mode

Setting Up The View

At this point, we have three components:

  1. ColorPicker - the overall component that we will use
  2. PredefinedSelector - the colour palette selector
  3. FreeSelector - the colour map selector

We proceed to set up the view for each of the components, beginning with the selectors. Let's get the most difficult component out of the way first - the FreeSelector.

As I mentioned earlier, the HSV colour model maps well to a 3D linear gradient. HSV (hue, saturation, value) are each a numerical representation that we can be split into a one-dimensional hue map and a two-dimensional saturation (x) and value (y) map. In order to render these maps, we use the linear-gradient CSS function. Let's see some code.

// FreeSelector.css

...

.cp-saturation {
  width: 100%;
  height: 150px;
  /* This provides a smooth representation 
     of brightness, which we overlay with an 
     inline background-color for saturation */
  background-image: linear-gradient(transparent, black),
    linear-gradient(to right, white, transparent);
  border-radius: 4px;
  /* This allows us to position an absolute
     indicator over the map */
  position: relative;
  cursor: crosshair;
}

.cp-hue {
  width: 100%;
  height: 12px;
  /* This covers the full range of hues */
  background-image: linear-gradient(
    to right,
    #ff0000,
    #ffff00,
    #00ff00,
    #00ffff,
    #0000ff,
    #ff00ff,
    #ff0000
  );
  border-radius: 999px;
  /* This allows us to position an absolute
     indicator over the map */
  position: relative;
  cursor: crosshair;
}

...
Enter fullscreen mode Exit fullscreen mode
// FreeSelector.tsx

import "./FreeSelector.css";

...

export const FreeSelector = (props: FreeSelectorProps) => {
  ...

  return (
    <div className="cp-free-root">
      <div
        className="cp-saturation"
        style={{
          backgroundColor: `hsl(${parsedColor.hsv.h}, 100%, 50%)`
        }}
        onClick={onSaturationChange}
      >
        // TODO: create an indicator to show current x,y position
      </div>
      <div className="cp-hue" onClick={onHueChange}>
        // TODO: create an indicator to show current hue
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

In the code above, you might be wondering where parsedColor.hsv.h comes from. This is the hue representation for the HSV colour model. As noted earlier, we need to convert the color string into the HSV representation. We will cover that later on. For now, we finish up the FreeSelector view. Here is the complete code for the FreeSelector.

// FreeSelector.css

.cp-free-root {
  display: grid;
  grid-gap: 8px;
  margin-bottom: 16px;
  max-width: 100%;
  width: 400px;
}

.cp-saturation {
  width: 100%;
  height: 150px;
  background-image: linear-gradient(transparent, black),
    linear-gradient(to right, white, transparent);
  border-radius: 4px;
  position: relative;
  cursor: crosshair;
}

.cp-saturation-indicator {
  width: 15px;
  height: 15px;
  border: 2px solid #ffffff;
  border-radius: 50%;
  transform: translate(-7.5px, -7.5px);
  position: absolute;
}

.cp-hue {
  width: 100%;
  height: 12px;
  background-image: linear-gradient(
    to right,
    #ff0000,
    #ffff00,
    #00ff00,
    #00ffff,
    #0000ff,
    #ff00ff,
    #ff0000
  );
  border-radius: 999px;
  position: relative;
  cursor: crosshair;
}

.cp-hue-indicator {
  width: 15px;
  height: 15px;
  border: 2px solid #ffffff;
  border-radius: 50%;
  transform: translate(-7.5px, -2px);
  position: absolute;
}
Enter fullscreen mode Exit fullscreen mode
// FreeSelector.tsx

import React, { MouseEventHandler } from "react";
import { Color } from "../../Interfaces/Color";
import "./FreeSelector.css";

interface FreeSelectorProps {
  parsedColor: Color;
  satCoords: Array<number>;
  hueCoords: number;
  onSaturationChange: MouseEventHandler;
  onHueChange: MouseEventHandler;
}

export const FreeSelector = (props: FreeSelectorProps) => {
  const {
    parsedColor,
    satCoords,
    hueCoords,
    onSaturationChange,
    onHueChange
  } = props;

  return (
    <div className="cp-free-root">
      <div
        className="cp-saturation"
        style={{
          backgroundColor: `hsl(${parsedColor.hsv.h}, 100%, 50%)`
        }}
        onClick={onSaturationChange}
      >
        <div
          className="cp-saturation-indicator"
          style={{
            backgroundColor: parsedColor.hex,
            left: (satCoords?.[0] ?? 0) + "%",
            top: (satCoords?.[1] ?? 0) + "%"
          }}
        />
      </div>
      <div className="cp-hue" onClick={onHueChange}>
        <div
          className="cp-hue-indicator"
          style={{
            backgroundColor: parsedColor.hex,
            left: (hueCoords ?? 0) + "%"
          }}
        />
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

We finally use the satCoords and hueCoords. These are used to position the indicators for the saturation map and hue map respectively. With the CSS properties position, left, and top, we can position the indicator accurately. Notice that we also use the transform property to adjust for the width and height of the indicator.

Congrats! The most difficult component is done!

Now, the PredefinedSelector looks simple enough. All we need is a palette of preview colours. Here is the complete code for the PredefinedSelector.

// PredefinedSelector.css

.cp-predefined-root {
  padding-bottom: 16px;
  display: flex;
  flex-direction: column;
  flex-wrap: wrap;
  max-width: 100%;
  min-width: 200px;
  overflow: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.cp-predefined-root::-webkit-scrollbar {
  display: none;
}

.cp-color-button {
  width: 37px;
  padding: 5px;
  border-radius: 4px;
  background-color: inherit;
}

.cp-preview-color {
  /* Shadow so we can see white against white */
  box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2),
    0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 2px 1px -1px rgba(0, 0, 0, 0.12);
  width: 25px;
  height: 25px;
  border-radius: 50%;
}

Enter fullscreen mode Exit fullscreen mode
// PredefinedSelector.tsx

import React from "react";
import { Color } from "../../Interfaces/Color";
import "./PredefinedSelector.css";

const predefinedRows = 3;

interface PredefinedSelectorProps {
  parsedColor: Color;
  colors: Array<string>;
  onSelect(color: string): void;
}

export const PredefinedSelector = (props: PredefinedSelectorProps) => {
  const { parsedColor, colors, onSelect } = props;

  return (
    <div
      className="cp-predefined-root"
      style={{
        height: 2 + 35 * predefinedRows + "px",
        width: 16 + 35 * Math.ceil(colors.length / predefinedRows) + "px"
      }}
    >
      {colors.map((color) => (
        <button
          className="cp-color-button"
          key={color}
          onClick={(event) => onSelect(color)}
          style={{
            border: color === parsedColor?.hex ? "1px solid #000000" : "none"
          }}
        >
          <div
            className="cp-preview-color"
            style={{
              background: color
            }}
          />
        </button>
      ))}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Here, we set the height and width of the root container based on the number of rows we would like and the total number of colours in our palette. We then loop through the colors array to populate the palette with our predefined colours.

Next, we move on to the main ColorPicker component. Now that we're done with the selectors, the only thing new are the inputs. Let's add the views for them.

// ColorPicker.css

.cp-container {
  padding: 12px;
  overflow: auto;
  scrollbar-width: none;
  -ms-overflow-style: none;
  width: fit-content;
}

.cp-container::-webkit-scrollbar {
  display: none;
}

.cp-input-container {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  margin: 2px;
}

.cp-input-group {
  display: grid;
  grid-template-columns: auto auto auto;
  grid-gap: 8px;
  align-items: center;
}

.cp-color-preview {
  /* Shadow so we can see white against white */
  box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2),
    0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 2px 1px -1px rgba(0, 0, 0, 0.12);
  width: 25px;
  height: 25px;
  border-radius: 50%;
}

input {
  padding: 4px 6px;
}

label,
input {
  display: block;
}

.cp-input-label {
  font-size: 12px;
}

.cp-hex-input {
  width: 60px;
}

.cp-rgb-input {
  width: 30px;
}
Enter fullscreen mode Exit fullscreen mode
// ColorPicker.tsx

export const ColorPicker = (props: ColorPickerProps) => {
  ...

  return (
    <div className="cp-container">
      // TODO: add selectors

      <div className="cp-input-container">
        <div className="cp-input-group">
          <div
            className="cp-color-preview"
            style={{
              background: color
            }}
          />
          <div>
            <label className="cp-input-label" htmlFor="cp-input-hex">
              Hex
            </label>
            <input
              id="cp-input-hex"
              className="cp-hex-input"
              placeholder="Hex"
              value={parsedColor?.hex}
              onChange={handleHexChange}
            />
          </div>
        </div>

        <div className="cp-input-group">
          <div>
            <label className="cp-input-label" htmlFor="cp-input-r">
              R
            </label>
            <input
              id="cp-input-r"
              className="cp-rgb-input"
              placeholder="R"
              value={parsedColor.rgb.r}
              onChange={(event) => handleRgbChange("r", event.target.value)}
              inputMode="numeric"
              pattern="[0-9]*"
            />
          </div>
          <div>
            <label className="cp-input-label" htmlFor="cp-input-g">
              G
            </label>
            <input
              id="cp-input-g"
              className="cp-rgb-input"
              placeholder="G"
              value={parsedColor.rgb.g}
              onChange={(event) => handleRgbChange("g", event.target.value)}
              inputMode="numeric"
              pattern="[0-9]*"
            />
          </div>
          <div>
            <label className="cp-input-label" htmlFor="cp-input-b">
              B
            </label>
            <input
              id="cp-input-b"
              className="cp-rgb-input"
              placeholder="B"
              value={parsedColor.rgb.b}
              onChange={(event) => handleRgbChange("b", event.target.value)}
              inputMode="numeric"
              pattern="[0-9]*"
            />
          </div>
        </div>
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Handling Color Model and Conversions

Until now, we have not added any logic to handle events in the view. Before we can do that, we'll need to set up the Color model and the conversion methods between the various colour representations. There are three colour representations that are important for our picker: Hex, RGB and HSV. We thus define the Color model:

// Color.ts

export interface Color {
  hex: string;
  rgb: ColorRGB;
  hsv: ColorHSV;
}

export interface ColorRGB {
  r: number;
  g: number;
  b: number;
}

export interface ColorHSV {
  h: number;
  s: number;
  v: number;
}
Enter fullscreen mode Exit fullscreen mode

With a bit of Googling, we can find pre-existing methods for colour conversion. Here are the methods I used.

// Converters.ts

import { ColorHSV, ColorRGB } from "../Interfaces/Color";

export function rgbToHex(color: ColorRGB): string {
  var { r, g, b } = color;
  var hexR = r.toString(16);
  var hexG = g.toString(16);
  var hexB = b.toString(16);

  if (hexR.length === 1) hexR = "0" + r;
  if (hexG.length === 1) hexG = "0" + g;
  if (hexB.length === 1) hexB = "0" + b;

  return "#" + hexR + hexG + hexB;
}

export function hexToRgb(color: string): ColorRGB {
  var r = 0;
  var g = 0;
  var b = 0;

  // 3 digits
  if (color.length === 4) {
    r = Number("0x" + color[1] + color[1]);
    g = Number("0x" + color[2] + color[2]);
    b = Number("0x" + color[3] + color[3]);

    // 6 digits
  } else if (color.length === 7) {
    r = Number("0x" + color[1] + color[2]);
    g = Number("0x" + color[3] + color[4]);
    b = Number("0x" + color[5] + color[6]);
  }

  return {
    r,
    g,
    b
  };
}

export function rgbToHsv(color: ColorRGB): ColorHSV {
  var { r, g, b } = color;
  r /= 255;
  g /= 255;
  b /= 255;

  const max = Math.max(r, g, b);
  const d = max - Math.min(r, g, b);

  const h = d
    ? (max === r
        ? (g - b) / d + (g < b ? 6 : 0)
        : max === g
        ? 2 + (b - r) / d
        : 4 + (r - g) / d) * 60
    : 0;
  const s = max ? (d / max) * 100 : 0;
  const v = max * 100;

  return { h, s, v };
}

export function hsvToRgb(color: ColorHSV): ColorRGB {
  var { h, s, v } = color;
  s /= 100;
  v /= 100;

  const i = ~~(h / 60);
  const f = h / 60 - i;
  const p = v * (1 - s);
  const q = v * (1 - s * f);
  const t = v * (1 - s * (1 - f));
  const index = i % 6;

  const r = Math.round([v, q, p, p, t, v][index] * 255);
  const g = Math.round([t, v, v, q, p, p][index] * 255);
  const b = Math.round([p, p, t, v, v, q][index] * 255);

  return {
    r,
    g,
    b
  };
}
Enter fullscreen mode Exit fullscreen mode

Remember the parsedColor object we were accessing earlier? We also need a method to convert a string representation of a colour into our Color model.

// ColorUtils.ts

import { Color, ColorRGB } from "../Interfaces/Color";
import { hexToRgb, rgbToHex, rgbToHsv } from "./Converters";

export function getRgb(color: string): ColorRGB {
  const matches = /rgb\((\d+),\s?(\d+),\s?(\d+)\)/i.exec(color);
  const r = Number(matches?.[1] ?? 0);
  const g = Number(matches?.[2] ?? 0);
  const b = Number(matches?.[3] ?? 0);

  return {
    r,
    g,
    b
  };
}

export function parseColor(color: string): Color {
  var hex = "";
  var rgb = {
    r: 0,
    g: 0,
    b: 0
  };
  var hsv = {
    h: 0,
    s: 0,
    v: 0
  };

  if (color.slice(0, 1) === "#") {
    hex = color;
    rgb = hexToRgb(hex);
    hsv = rgbToHsv(rgb);
  } else if (color.slice(0, 3) === "rgb") {
    rgb = getRgb(color);
    hex = rgbToHex(rgb);
    hsv = rgbToHsv(rgb);
  }

  return {
    hex,
    rgb,
    hsv
  };
}

export function getSaturationCoordinates(color: Color): [number, number] {
  const { s, v } = rgbToHsv(color.rgb);

  const x = s;
  const y = 100 - v;

  return [x, y];
}

export function getHueCoordinates(color: Color): number {
  const { h } = color.hsv;

  const x = (h / 360) * 100;

  return x;
}

export function clamp(number: number, min: number, max: number): number {
  if (!max) {
    return Math.max(number, min) === min ? number : min;
  } else if (Math.min(number, min) === number) {
    return min;
  } else if (Math.max(number, max) === number) {
    return max;
  }
  return number;
}
Enter fullscreen mode Exit fullscreen mode

In the utils file above, I also included the getSaturationCoordinates and getHueCoordinates methods to position our indicators. If you notice, the HSV model maps very nicely into our linear gradients since s and v are percentages. Hue maps to a 360 degree circle, so we need to normalize the value for our linear scale.

Adding Handlers and Logic

Finally, we are ready to add our handlers, which is the last piece of the puzzle. At this point, the only incomplete component is the overall ColorPicker. So let's go back to that.

// ColorPicker.tsx

export const ColorPicker = (props: ColorPickerProps) => {
  const { color, colors, onChange, variant } = props;

  const parsedColor = useMemo(() => parseColor(color), [color]);
  const satCoords = useMemo(() => getSaturationCoordinates(parsedColor), [
    parsedColor
  ]);
  const hueCoords = useMemo(() => getHueCoordinates(parsedColor), [
    parsedColor
  ]);

  const handleHexChange = useCallback(
    (event) => {
      var val = event.target.value;
      if (val?.slice(0, 1) !== "#") {
        val = "#" + val;
      }
      onChange(val);
    },
    [onChange]
  );

  const handleRgbChange = useCallback(
    (component, value) => {
      const { r, g, b } = parsedColor.rgb;

      switch (component) {
        case "r":
          onChange(rgbToHex({ r: value ?? 0, g, b }));
          return;
        case "g":
          onChange(rgbToHex({ r, g: value ?? 0, b }));
          return;
        case "b":
          onChange(rgbToHex({ r, g, b: value ?? 0 }));
          return;
        default:
          return;
      }
    },
    [parsedColor, onChange]
  );

  const handleSaturationChange = useCallback(
    (event) => {
      const { width, height, left, top } = event.target.getBoundingClientRect();

      const x = clamp(event.clientX - left, 0, width);
      const y = clamp(event.clientY - top, 0, height);

      const s = (x / width) * 100;
      const v = 100 - (y / height) * 100;

      const rgb = hsvToRgb({ h: parsedColor?.hsv.h, s, v });

      onChange(rgbToHex(rgb));
    },
    [parsedColor, onChange]
  );

  const handleHueChange = useCallback(
    (event) => {
      const { width, left } = event.target.getBoundingClientRect();
      const x = clamp(event.clientX - left, 0, width);
      const h = Math.round((x / width) * 360);

      const hsv = { h, s: parsedColor?.hsv.s, v: parsedColor?.hsv.v };
      const rgb = hsvToRgb(hsv);

      onChange(rgbToHex(rgb));
    },
    [parsedColor, onChange]
  );

  ...
};
Enter fullscreen mode Exit fullscreen mode

We start off by parsing the color string received as prop. Once we get the parsedColor, we can retrieve the satCoords and hueCoords using our getters. We then define the handlers for the change events in our selectors - handleHexChange, handleRgbChange, handleSaturationChange and handleHueChange. handleSaturationChange and handleHueChange are just the inverse functions of getSaturationCoordinates and getHueCoordinates.

And.. we're done with the colour picker! Here is the complete code for the ColorPicker.

// ColorPicker.tsx

import React, { useCallback, useMemo } from "react";
import {
  clamp,
  DEFAULT_COLOR,
  DEFAULT_COLORS,
  getHueCoordinates,
  getSaturationCoordinates,
  hsvToRgb,
  parseColor,
  rgbToHex
} from "../Utils";
import "./ColorPicker.css";
import { FreeSelector, PredefinedSelector } from "./Options";

export enum ColorPickerVariant {
  Predefined = "predefined",
  Free = "free"
}

interface ColorPickerProps {
  color: string;
  colors: Array<string>;
  onChange(color: string): void;
  variant: ColorPickerVariant;
}

export const ColorPicker = (props: ColorPickerProps) => {
  const { color, colors, onChange, variant } = props;

  const parsedColor = useMemo(() => parseColor(color), [color]);
  const satCoords = useMemo(() => getSaturationCoordinates(parsedColor), [
    parsedColor
  ]);
  const hueCoords = useMemo(() => getHueCoordinates(parsedColor), [
    parsedColor
  ]);

  const handleHexChange = useCallback(
    (event) => {
      var val = event.target.value;
      if (val?.slice(0, 1) !== "#") {
        val = "#" + val;
      }
      onChange(val);
    },
    [onChange]
  );

  const handleRgbChange = useCallback(
    (component, value) => {
      const { r, g, b } = parsedColor.rgb;

      switch (component) {
        case "r":
          onChange(rgbToHex({ r: value ?? 0, g, b }));
          return;
        case "g":
          onChange(rgbToHex({ r, g: value ?? 0, b }));
          return;
        case "b":
          onChange(rgbToHex({ r, g, b: value ?? 0 }));
          return;
        default:
          return;
      }
    },
    [parsedColor, onChange]
  );

  const handleSaturationChange = useCallback(
    (event) => {
      const { width, height, left, top } = event.target.getBoundingClientRect();

      const x = clamp(event.clientX - left, 0, width);
      const y = clamp(event.clientY - top, 0, height);

      const s = (x / width) * 100;
      const v = 100 - (y / height) * 100;

      const rgb = hsvToRgb({ h: parsedColor?.hsv.h, s, v });

      onChange(rgbToHex(rgb));
    },
    [parsedColor, onChange]
  );

  const handleHueChange = useCallback(
    (event) => {
      const { width, left } = event.target.getBoundingClientRect();
      const x = clamp(event.clientX - left, 0, width);
      const h = Math.round((x / width) * 360);

      const hsv = { h, s: parsedColor?.hsv.s, v: parsedColor?.hsv.v };
      const rgb = hsvToRgb(hsv);

      onChange(rgbToHex(rgb));
    },
    [parsedColor, onChange]
  );

  return (
    <div className="cp-container">
      {variant === ColorPickerVariant.Predefined ? (
        <PredefinedSelector
          colors={colors}
          parsedColor={parsedColor}
          onSelect={onChange}
        />
      ) : (
        <FreeSelector
          parsedColor={parsedColor}
          satCoords={satCoords}
          hueCoords={hueCoords}
          onSaturationChange={handleSaturationChange}
          onHueChange={handleHueChange}
        />
      )}

      <div className="cp-input-container">
        <div className="cp-input-group">
          <div
            className="cp-color-preview"
            style={{
              background: color
            }}
          />
          <div>
            <label className="cp-input-label" htmlFor="cp-input-hex">
              Hex
            </label>
            <input
              id="cp-input-hex"
              className="cp-hex-input"
              placeholder="Hex"
              value={parsedColor?.hex}
              onChange={handleHexChange}
            />
          </div>
        </div>

        <div className="cp-input-group">
          <div>
            <label className="cp-input-label" htmlFor="cp-input-r">
              R
            </label>
            <input
              id="cp-input-r"
              className="cp-rgb-input"
              placeholder="R"
              value={parsedColor.rgb.r}
              onChange={(event) => handleRgbChange("r", event.target.value)}
              inputMode="numeric"
              pattern="[0-9]*"
            />
          </div>
          <div>
            <label className="cp-input-label" htmlFor="cp-input-g">
              G
            </label>
            <input
              id="cp-input-g"
              className="cp-rgb-input"
              placeholder="G"
              value={parsedColor.rgb.g}
              onChange={(event) => handleRgbChange("g", event.target.value)}
              inputMode="numeric"
              pattern="[0-9]*"
            />
          </div>
          <div>
            <label className="cp-input-label" htmlFor="cp-input-b">
              B
            </label>
            <input
              id="cp-input-b"
              className="cp-rgb-input"
              placeholder="B"
              value={parsedColor.rgb.b}
              onChange={(event) => handleRgbChange("b", event.target.value)}
              inputMode="numeric"
              pattern="[0-9]*"
            />
          </div>
        </div>
      </div>
    </div>
  );
};

ColorPicker.defaultProps = {
  color: DEFAULT_COLOR,
  colors: DEFAULT_COLORS,
  onChange: (color: string) => {},
  variant: ColorPickerVariant.Predefined
};
Enter fullscreen mode Exit fullscreen mode

Concluding Remarks

Again, the complete code can be found here. This is an implementation with no dependencies beyond React, but you can always use your favourite UI libraries to replace the views. I would also like to credit react-color-palette and this css-tricks article as I used them as reference for the colour map implementation and colour conversions methods.

Discussion (1)

Collapse
yaireo profile image
Yair Even Or

There is no logic in building color picker in React because you are putting a lot of effort one something which can only fit a single eco-system (React)

I am the author of a very good color minimal picker, and almost all my components in vanilla JS so they could be used in any framework

npmjs.com/package/@yaireo/color-pi...

Since this component is most-likely meant to be rendered as a popup, it can be easily integrated with existing React code, since the rendered output is injected to the <body> level