React Color Picker

A Color palette

Photo by matthaeus on Unsplash

Does my job boil down to giving the correct color to a pixel at the right place? 🤔

A word of caution

The blog post hasn't even started and I'm already here with a warning 🤦.

Color pickers are often found inside a dialog or a dropdown. Building those components so that they have proper UX and behave as expected is not trivial.

Being that this is not the main focus of this blog post, I'm going to assume that the <ColorPicker> will be always visible.

Also, this is not a tutorial on React specifically, most knowledge will be taken for granted.

What are we going to build?

What is the correct way to represent a color picker?

Depending on your background you may have used software with different representations of color pickers.

For this blog post, I decided to build something similar to the VSCode / developer console color picker, which I assume is the one developers are familiar with.

Color picker sketch

Color picker sketch made with Excalidraw

Theory Behind The Picker

This is not going to be an in-depth explanation about the color model that I've chosen to use or about how colors work, it's more about getting everyone on the same page.

I've decided to use the HSV color model for the internal state because it's the easiest when it comes to doing math and representing it with a <canvas>.

From Wikipedia: The HSV color model is an alternative representation of the RGB color model designed in the 1970s by computer graphics researchers to more closely align with the way human vision perceives color-making attributes.

The "correct" way to represent this model is by using a cylinder which is a 3D representation. We want to have something in 2D for our picker so we need to cut into the cylinder. A picture is worth a thousand words, here is what I mean. The section we care about is highlighted in red.

HSV 3D representation

HSV Cylinder by Wikipedia

The picker will support different color models. The color conversion functions I've used are either adapted from Wikipedia or Stackoverflow. You can find the source links along with my version on Github, but I won't explain them in this blog post.

If you want to know more about colors I recommend this Twitter thread which is full of very good articles written by people way more qualified than me 😆.

The Wrapper

Let's start by building a wrapper component for our picker. Here we will hold the internal state of the picker which is going to be an object containing the hue, saturation, value and alpha.

A color picker will generally receive two things from its parent: a callback function that will be called whenever the color changes and a starting color.

TsxTsx codeblock / snippet / file
Copied 🎉
1interface ColorPickerProps {
2 onChange?: (color: string) => void;
3 startColor?: string;
6export type Color = {
7 hue: number;
8 saturation: number;
9 value: number;
10 alpha: number;
13export const ColorPicker: React.FC<ColorPickerProps> = ({
14 onChange,
15 startColor,
16}) => {
17 const [color, setColor] = React.useState<Color>(
18 () => validateStartColor(startColor)
19 );
21 React.useEffect(() => {
22 if (onChange) {
23 const hex = hsvToHex(
24 color.hue,
25 color.saturation,
26 color.value,
27 color.alpha
28 );
29 if (hex) {
30 onChange("#" + hex);
31 }
32 }
33 }, [color, onChange]);
35 return (
36 <div className={style.color_picker}>
37 </div>
38 );

The onChange callback receives a hex code trying to mimic the <input type="color"> behavior.

Hue and Alpha

Whenever is possible I try to use native HTML elements instead of building a custom one. Given that both hue and alpha can be expressed with a range, 0-360 and 0-1 respectively, it makes sense to use the <input type="range"> element. Even if this means you have to add 300 lines of CSS to make it pretty 🙃.

It's not all bad, though, the advantages outweigh the disadvantages. You get accessibility, validation, and keyboard controls by default which are always tricky to implement in custom inputs.

Let's start with the hue range input. Most people cannot reason about colors using a value between 0°-360° so let's add a colored background.

We need to create a gradient between six colors: red, yellow, green, cyan, blue, magenta.

"Unwrapping" the Hue circle

Doing this in CSS is quite easy using the linear-gradient function. It's made even easier thanks to the use of percentages to declare color stops.

SassSass codeblock / snippet / file
Copied 🎉
.hue_range_input {
-webkit-appearance: none;
background: linear-gradient(
to right,
rgb(255, 0, 0) 0%,
rgb(255, 255, 0) 17%,
rgb(0, 255, 0) 33%,
rgb(0, 255, 255) 50%,
rgb(0, 0, 255) 67%,
rgb(255, 0, 255) 83%,
rgb(255, 0, 0) 100%

While we are at it let's style what is commonly referred to as the thumb of the input with the currently selected color. It may seem as easy as setting background-color: transparent and let the underlying gradient pass through. Since the thumb is generally bigger than the track doing this would result in the thumb being only half colored. To fix this we need some good old javascript.

Taking advantages of the fact that CSS variables are reactive and that we can update them using javascript, it becomes pretty easy to get our desired effect, resulting in a component that looks like this:

TsxTsx codeblock / snippet / file
Copied 🎉
1interface HueRangeInputProps {
2 hue: number;
3 setHue: (h: number) => void;
6const HueRangeInput: React.FC<HueRangeInputProps> = ({ hue, setHue }) => {
7 const inputRef = React.useRef<HTMLInputElement | null>(null);
9 React.useEffect(() => {
10 if (inputRef.current) {
12 "--thumb-color",
13 `hsl(${hue}, 100%, 50%)`
14 );
15 }
16 }, [hue]);
18 return (
19 <input
20 aria-label="Hue"
21 className={style.hue_range_input}
22 type="range"
23 min={0}
24 max={360}
25 ref={inputRef}
26 value={hue}
27 onChange={(e) => {
28 setHue(Number(;
29 }}
30 />
31 );
SassSass codeblock / snippet / file
Copied 🎉
.range_inputs_container {
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
background: var(--thumb-color);
input[type="range"]::-moz-range-thumb {
background: var(--thumb-color);

Let's add some CSS and we get this result.

The alpha range input is almost an exact copy of this component with a small change. Since we are dealing with opacity we need another background for when the range input becomes too transparent so that we don't blend in with the website background color.

We also need to pass the whole color object since we are going to use the RGB color model for this gradient.

For the sake of brevity, I'll only show the differences.

TsxTsx codeblock / snippet / file
Copied 🎉
1interface AlphaRangeInputProps {
2 color: Color;
3 setAlpha: (a: number) => void;
6const AlphaRangeInput: React.FC<AlphaRangeInputProps> = ({
7 color,
8 setAlpha,
9}) => {
10 const inputRef = React.useRef<HTMLInputElement | null>(null);
12 React.useEffect(() => {
13 if (inputRef.current) {
14 const { hue, saturation, lightness } = hsvToHsl(
15 color.hue,
16 color.saturation,
17 color.value
18 );
20 "--thumb-color",
21 `hsla(${hue}, ${saturation}%, ${lightness}%, ${color.alpha})`
22 );
23 }
24 }, [color]);
26 const { r, g, b } = hsvToRgb(color.hue, color.saturation, color.value);
28 return (
29 <div className={style.alpha_background_checkered}>
30 <input
31 aria-label="Alpha"
32 className={style.alpha_range_input}
33 type="range"
34 min={0}
35 max={1}
36 step={0.01}
37 ref={inputRef}
38 value={color.alpha}
39 onChange={(e) => {
40 setAlpha(Number(;
41 }}
42 style={{
43 background: `linear-gradient(to right, rgba(${r}, ${g}, ${b}, 0) 0%, rgb(${r}, ${g}, ${b}, 1) 100%)`,
44 }}
45 />
46 </div>
47 );

The Canvas

The canvas component is the core of the color picker. It shares many similarities with the previous components but there is one fundamental difference. There is no native element that can represent a "2D" range input. We could use 2 range inputs, one horizontal and one vertical if we really wanted to use the platform ™.

This is unfortunately one of those cases where using the native elements just doesn't cut it and it would result in a bad user experience.

The good news? We can style it without using pseudo-element selectors 🥳.

The first thing we need is, unsurprisingly, a <canvas>, its context and its size.

TsxTsx codeblock / snippet / file
Copied 🎉
1interface GradientCanvasProps {
2 color: Color;
3 setColor: (c: Color) => void;
6export const GradientCanvas: React.FC<GradientCanvasProps> = ({
7 color,
8 setColor,
9}) => {
10 const [size, setSize] = useState<DOMRect | null>(null);
11 const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
13 const ref = useCallback((node: HTMLCanvasElement | null) => {
14 if (node !== null) {
15 setSize(node.getBoundingClientRect());
16 ctxRef.current = node.getContext("2d");
17 }
18 }, []);
20 return (
21 <div className={style.gradient_canvas_container}>
22 <canvas ref={ref} width={size?.width} height={size?.height} />
23 </div>
24 );

I'm passing a callback ref to the canvas element. Wrapping the function inside a useCallback hook prevents a new function from being created on each render and acts as a sort of componentDidMount function.

Now we need to display the gradient. To get the desired effect we are going to paint the canvas with our color, which is going to be in RGBa, then we paint over it with two gradients:

  1. A horizontal white gradient, from left to right, from 1 to 0 alpha.
  2. A vertical black gradient, from top to bottom, from 0 to 1 alpha.
TypescriptTypescript codeblock / snippet / file
Copied 🎉
export const fillCanvas = (
ctx: CanvasRenderingContext2D,
rgbaColor: string,
width: number,
height: number
): void => {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = rgbaColor;
ctx.fillRect(0, 0, width, height);
const whiteGradient = ctx.createLinearGradient(0, 0, width, 0);
whiteGradient.addColorStop(0, "rgba(255, 255, 255, 1)");
whiteGradient.addColorStop(1, "rgba(255, 255, 255, 0)");
ctx.fillStyle = whiteGradient;
ctx.fillRect(0, 0, width, height);
const blackGradient = ctx.createLinearGradient(0, 0, 0, height);
blackGradient.addColorStop(0, "rgba(0, 0, 0, 0)");
blackGradient.addColorStop(1, "rgba(0, 0, 0, 1)");
ctx.fillStyle = blackGradient;
ctx.fillRect(0, 0, width, height);

We are going to use this function inside an useEffect hook that will run every time the color changes.

TsxTsx codeblock / snippet / file
Copied 🎉
React.useEffect(() => {
if (size && ctxRef.current) {
const { r, g, b } = hsvToRgb(hue, 100, 100);
`rgba(${r}, ${g}, ${b}, ${alpha})`,
}, [size, hue, alpha]);

Right now if we change the hue or the alpha, the canvas will be updated with a new gradient. Cool!

Let's add the core functionalities.

First, we need a checkered background similar to the one we used in the AlphaRangeInput component. The reason is the same, it's to avoid blending with the background when the alpha becomes too low.

TsxTsx codeblock / snippet / file
Copied 🎉
return (
<div className={style.gradient_canvas_container}>
<div className={style.canvas_bg_checkered} />
<canvas ref={ref} width={size?.width} height={size?.height} />
SassSass codeblock / snippet / file
Copied 🎉
.gradient_canvas_container {
position: relative;
width: 300px;
height: 300px;
margin: 0 0 1rem 0;
canvas {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
&:hover {
cursor: pointer;
.canvas_bg_checkered {
background-image: linear-gradient(45deg, #acacac 25%, transparent 25%),
linear-gradient(-45deg, #acacac 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #acacac 75%),
linear-gradient(-45deg, transparent 75%, #acacac 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
height: 100%;
width: 100%;

Now we need to add a way to pick a color from the canvas.To do that, we need a marker that will be positioned on the currently selected color. The marker is composed of two transparent <div>s. The smaller one will have a white border and the bigger one a black border. This is done to avoid losing the picker when going over very light/dark colors. To position the marker we need to find its coordinates inside the canvas. To find the x we divide our saturation by 100 and multiply it for the canvas width. To find the y we divide our value by 100, multiply it for the canvas height and then subtract the result of this operation from the canvas height. This is because the origin of the canvas is on the top left.

TsxTsx codeblock / snippet / file
Copied 🎉
const canvasCursorSize = 20;
const cursorX = size ? (saturation / 100) * size.width : 0;
const cursorY = size ? size.height - (value / 100) * size.height : 0;
return (
width: `${canvasCursorSize}px`,
height: `${canvasCursorSize}px`,
transform: `translate(${cursorX - canvasCursorSize / 2}px,${
cursorY - canvasCursorSize / 2
<div />
SassSass codeblock / snippet / file
Copied 🎉
.canvas_cursor {
position: absolute;
top: 0;
left: 0;
border: 3px solid black;
border-radius: 50%;
div {
width: 100%;
height: 100%;
border: 2px solid white;
border-radius: 50%;

Now we need to implement arguably the most important part, the ability for the user to pick a color. We are going to support both pointer events (with a fallback on touch events) and keyboard events.

Let's start with the keyboard events as they are extremely easy. Taking inspiration from the <input type="range"> controls, we are going to move the marker using our arrow keys.

The Left and Right arrows are going to be respectively -1 and +1 saturation, and the Up and Down arrows, -1 and +1 value.

TsxTsx codeblock / snippet / file
Copied 🎉
2 //
3 onKeyDown={(e) => {
4 // don't prevent default here or it may mess up tabbing ecc..
5 if (e.key === "ArrowUp") {
6 e.preventDefault();
7 setColor({
8 ...color,
9 value: Math.min(value + 1, 100),
10 });
11 } else if (e.key === "ArrowDown") {
12 e.preventDefault();
13 setColor({
14 ...color,
15 value: Math.max(value - 1, 0),
16 });
17 } else if (e.key === "ArrowLeft") {
18 e.preventDefault();
19 setColor({
20 ...color,
21 saturation: Math.max(saturation - 1, 0),
22 });
23 } else if (e.key === "ArrowRight") {
24 e.preventDefault();
25 setColor({
26 ...color,
27 saturation: Math.min(saturation + 1, 100),
28 });
29 }
30 }}}
31 tabIndex={0}
32 //

To implement the pointer events we are going to use the pointerDown and pointerMove listeners.

Pointer events are great and have good coverage but they are not supported on older versions of some browsers.

Pointer Events caniuse

Pointer events support chart by Caniuse

We have already written the logic for positioning the marker from a given color, it's just a matter of reversing the operation. Given a point inside the canvas, we can easily map it to a color and once the state is updated the marker is going to move. This allows us to avoid storing the marker position inside the state and the various problems that arise whenever you have more than a single source of truth.

TsxTsx codeblock / snippet / file
Copied 🎉
onPointerDown={(e) => {
if (!size) return;
const [x, y] = getPointerPosition(
saturation: (x / size.width) * 100,
value: 100 - (y / size.height) * 100,

The setPointerCapture allows us to drag the pointer device outside the component while still retaining control over it until it is released. Another way to do this is to listen to events on the window or on the document interface.

The onPointerMove listener is the same, just wrapped inside the condition that e.buttons === 1 to check that the left mouse button is pressed, there is touch contact or there is pen contact. There is no need to setPointerCapture at this stage.

You may have noticed that this component is bugged 🐛 right now. If we scroll or resize the page, the marker will receive an offset from the position it was at since we never recalculate the bounding box of the canvas.

My first fix consisted of adding a resize and a scroll event listener inside the callbackRef. On "mount" they will be added and on "unmount" they will be removed. Unfortunately, this solution and other solutions that work the same way (think adding an useEffect with those event listeners) are far from perfect. They work well but getBoundingClientRect is somewhat expensive to call as it will trigger layout trashing.

To solve this problem you may want to debounce the function, cache the return value, only call it if the canvas is currently visible on the screen or you may want to go a different route and have the listeners store the offsets in some ref that you will pass to the getPointerPosition function.

As you can see there are multiple ways of solving this but I chose to tackle it differently. All of those solutions are overkill since we don't need to have the correct size at all times.

We only need to have the correct size on the first interaction with the picker, so let's recalculate the bounding box on the onPointerDown listener.

TsxTsx codeblock / snippet / file
Copied 🎉
onPointerDown={(e) => {
const bbox = e.currentTarget.getBoundingClientRect();
const [x, y] = getPointerPosition(
saturation: (x / bbox.width) * 100,
value: 100 - (y / bbox.height) * 100,

There is still a situation in which we could get the wrong measurements of our canvas. If the user tries to scroll or resize the page while he's interacting with the picker we have no way of updating the size.

Realistically there is very little chance of resizing happening when the user is interacting with the picker but scrolling, even if by a mistake, does happen. To fix this, when the user is interacting with the picker, we can either disable scrolling or adding a scroll or a resize event listeners to recalculate the bounding box that will be removed once they are done using the picker.

I didn't implement this on my demo as I couldn't see this situation happening enough to warrant adding a fix.

Touch Support

Touch events will serve as a fallback in case pointer events are not available on the user device.

Luckily enough I still have my old smartphone with iOS 12.4.9 installed so that I could properly test these functionalities 😅.

First of all, let's add an onTouchStart listener. This is very similar to our onPointerDown listener, the only differences are the removal of setPointerCapture and the usage of the event.touches interface to get the touch position.

At this point, I thought that there would be no problems and that onTouchMove would behave similarly to onPointerMove. I tried to implement it and I noticed that while I was interacting with the picker the screen would scroll.

This is because CSS touch-action: none isn't supported on the version of iOS I was using to test touch events.

The onTouchMove event scrolls the page.

The solution is to call event.preventDefault() inside our listener. Unfortunately, this does not work on React event listeners. If you try to call event.preventDefault() inside a listener you get an error.

Unable to preventDefault inside passive event listener invocation

By default, React treats certain events, including onTouchMove, as passive. This is due to better performance on passive listeners.

At the time of writing React does not expose an API to set { passive:false }, on JSX event listeners.

The usual way of adding and removing events outside of JSX is by using a useEffect hook. This guarantees that events are properly cleaned up after the component is unmounted.

To do this we need to grab a ref to our canvas. We are going to do this inside our callbackRef function.

TsxTsx codeblock / snippet / file
Copied 🎉
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const ref = useCallback((node: HTMLCanvasElement | null) => {
if (node !== null) {
ctxRef.current = node.getContext("2d");
canvasRef.current = node;
}, []);

Then we can add our listener.

TsxTsx codeblock / snippet / file
Copied 🎉
useEffect(() => {
const touchMove = (e: globalThis.TouchEvent) => {
if (!size) return;
const [x, y] = getPointerPosition(
saturation: (x / size.width) * 100,
value: 100 - (y / size.height) * 100,
canvasRef.current?.addEventListener("touchmove", touchMove, {
passive: false,
return () => {
}, [hue, alpha, size, setColor]);

For brevity's sake, I won't cover the onMouseDown and onMouseMove event listeners but they are almost an exact copy of the pointer ones.

The Inputs

One of the requirements of this color picker was to support different color models, namely, RGB, HSL, HSV and HEX. There isn't much of a difference between the first when it comes down to building React components, it's only a matter of converting the user input to HSV, which is the color model used internally in the picker. The HEX component is a bit trickier to implement.

To showcase an example component let's implement the RGB red channel component. It's going to be a typical React controlled input that receives the state from its parent.

TsxTsx codeblock / snippet / file
Copied 🎉
1interface RGBInputProps {
2 color: Color;
3 setColor: (c: Color) => void;
6export const RGBInput: React.FC<RGBInputProps> = ({ color, setColor }) => {
7 const { r, g, b } = hsvToRgb(color.hue, color.saturation, color.value);
8 return (
9 <input
10 aria-label="Rgb red"
11 type="number"
12 inputMode="numeric"
13 min={0}
14 max={255}
15 value={toColorInput(r)}
16 onChange={(e) => {
17 let val = Number( || 0;
18 if (val > 255) val = 255;
19 const hsv = rgbToHsv(val, g, b);
20 setColor({
21 hue: hsv.hue,
22 saturation: hsv.saturation,
23 value: hsv.value,
24 alpha: color.alpha,
25 });
26 }}
27 />
28 // Blue and Green are the same
29 );
TypescriptTypescript codeblock / snippet / file
Copied 🎉
export const toColorInput = (value: number): number => {
if (value !== 0) {
return Math.round(Number(value.toString().replace(/^0+/, "")));
return 0;

The HSL and HSV input components look the same, it's just a matter of validating the input between the appropriate range of possible values and if necessary convert it to HSV if needed.

The only component that differs is the <HEXInputComponent /> because while manually typing the hex code, there is no guarantee that we will always have valid HEX values that can be converted to HSV.

Since this component input value is controlled by our parent state, setting an invalid state would break our picker. We need some local state to manage invalid values.

The solution I came up with is to use an useState hook that will store our input value, allowing us to store invalid values and to use a useRef hook to store whether the component is focused or not.

When the component is focused it uses its state and when it loses focus we can validate and fall back to displaying our parent component state.

TsxTsx codeblock / snippet / file
Copied 🎉
1interface HEXInputProps {
2 color: Color;
3 setColor: (c: Color) => void;
6export const HEXInput: React.FC<HEXInputProps> = ({ color, setColor }) => {
7 const hex = hsvToHex(color.hue, color.saturation, color.value, color.alpha);
8 const [hexInputValue, setHexInputValue] = useState(hex);
9 const focusRef = useRef(false);
11 return (
12 <div>
13 <label htmlFor="hex-input">HEX</label>
14 <div>
15 <span>#</span>
16 <input
17 id="hex-input"
18 aria-label="Hex color"
19 onFocus={() => {
20 focusRef.current = true;
21 setHexInputValue(hex);
22 }}
23 onBlur={() => {
24 if (!hexInputValue.trim() || hexInputValue !== hex) {
25 setColor(color);
26 }
28 focusRef.current = false;
29 setHexInputValue(hex);
30 }}
31 value={focusRef.current ? hexInputValue : hex}
32 type="text"
33 onChange={(e) => {
34 if ( > 8) {
35 return;
36 }
38 setHexInputValue(;
40 const newHsv = hexToHsv(;
41 if (newHsv) {
42 setColor({ ...newHsv });
43 }
44 }}
45 />
46 </div>
47 </div>
48 );


You may have noticed that I didn't go in-depth about accessibility in this blog post and that I've only added what was necessary to make the color picker work.

Don't get me wrong, despite this section being the last of the blog post, accessibility was one the first things I considered and researched before building the color picker.

Although I consider myself a pretty good Google-fu practitioner 🕵️, I couldn't find anything relevant about building accessible color pickers. Most of the resources I've found are about accessible color palettes and contrast checking.

I was quite discouraged after finding out that many color pickers didn't even have the correct HTML5 elements, let alone proper accessibility.

It seems like accessibility is an afterthought at best. And I'd like to say that I'm not attacking the websites or the developers that have built those color pickers. It's a bigger problem that is radicated in the way that web development is taught, but this is a whole different topic that requires a blog post of its own.

Enough with the ranting, let's fix this!

Fixing The Inputs

The great advantage of using native HTML elements is that we don't have to do much to make it properly accessible!

When grouping multiple inputs make sure to use the <fieldset> element and the <legend> element to provide a caption to the group. Usually, these elements are used inside a <form> but it's perfectly valid to use them outside.

Giving a proper aria-label is also necessary if we don't use the <label> element.

Let's also add the min and max attributes to our <input type="number" /> elements. To facilitate the usage of on-screen keyboards we use the inputmode attribute (in React the m is capitalized) which is going to be:

  • inputMode="text" for the hex input.
  • inputMode="decimal" for the alpha input.
  • inputMode="numeric" for the rest of the inputs.

Since the alpha input deals with decimal numbers let's add the step attribute. This indicates the amount that is going to be added or removed to the input value when using the input controls. In our case, a value of 0.01 is perfect since alpha goes from 0 to 1.

Fixing The Canvas

The <canvas> element does not provide any API to make its content accessible. We are going to resort to using our trusty ARIA attributes and some mostly unknown tips.

First of all, we need an aria-label to indicate that our canvas is a color picker, an aria-description to describe how to use it and the available keyboard shortcuts, and an aria-valuetext with the current hex value.

According to MDN, we can put content inside the canvas element. This is going to be used as a fallback in case the browser doesn't support canvas rendering (extremely rare) but, more importantly, is helpful to assistive technology users.

The Result

Adding all the missing input components and some CSS 🎀 will give this result.

This is the complete picker, as always you are free to use the source code which can be found on Github.