24
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.
The colour picker we're about to build will have two different selection options:
- a predefined colour palette and
- a continuous colour map
Users may set colours using the selectors or key in colours in hex or rgb using inputs.
Based on the functional requirements, our colour picker will need 4 props:
-
color
- the current color selected -
colors
- the array of predefined colours for the colour palette -
onChange
- the handler when a new colour is selected -
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;
...
}
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;
...
}
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;
...
}
At this point, we have three components:
- ColorPicker - the overall component that we will use
- PredefinedSelector - the colour palette selector
- 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;
}
...
// 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>
);
};
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;
}
// 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>
);
};
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%;
}
// 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>
);
};
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;
}
// 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>
);
};
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;
}
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
};
}
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;
}
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.
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]
);
...
};
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
};
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.
24