Design a color picker
A design-system color picker: a saturation and brightness square, hue and alpha sliders, a hex field, an eyedropper, swatches and a live contrast check. The hard part is color itself: picking the model the user edits, keeping hue stable when a color turns gray, converting and rounding without drift, handling colors outside the screen's gamut, and making a two-dimensional drag area work with a keyboard and a screen reader.
The question
Design a color picker component for our design system. Product teams will use it for label colors, chart series colors, brand theme settings and a canvas editor's fill color.
Walk me through the color model you store, how the drag area and sliders work with a mouse, touch, keyboard and screen reader, how people type or paste a color, the eyedropper, swatches, a contrast check, and the component's props and events.
Real-life examples: The fill color picker in Figma, The color picker in Chrome DevTools, opened from a color swatch in the Styles pane, The label color picker on GitHub, Theme and accent color pickers in Slack and Notion, Libraries such as react-colorful and the React Aria ColorPicker
Requirements: one value, many ways to pick it
A color picker lets a person choose one color. Its output is tiny, a string like #3b82f6, but its input is not. People drag in a square, slide a hue bar, type a hex code, paste rgb(59 130 246) from a stylesheet, sample a pixel from the screen with an eyedropper, or click a brand swatch. Every one of these paths must land on the same value, and every one must work with a keyboard and a screen reader.
Open the pickers you know and you will see the same parts again and again. Chrome DevTools shows a square, a hue bar, an alpha bar, a value that switches between hex, rgb(), hsl(), oklch() and other formats, an eyedropper and a contrast ratio. Figma shows almost the same, plus document colors and a Display P3 option. GitHub's label picker is much smaller: a random-color button, a hex field and a row of preset swatches.
Questions to ask
The interview is about deciding which of these parts this design system needs and how they share one value. Each question below changes a real decision. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Who uses it, and for what? | Product teams: label colors, chart colors, theme settings, and a canvas editor's fill. | One flexible component built from parts, not one fixed popover. |
| Do we need transparency? | Yes, optional per use. Labels hide it, the canvas shows it. | An alpha channel in the model and an alpha slider part. |
| Which formats do people type and need back? | Type hex, rgb(), hsl() and oklch(). Store hex by default. | A parser for several formats and a format prop for output. |
| Wide gamut (Display P3)? | Only the canvas editor. Everyone else stays in sRGB. | A gamut option and a P3 boundary in the area, off by default. |
| Is there a background to check contrast against? | Often: text color on a card, label text on a label fill. | An optional contrastWith prop and a live ratio with AA and AAA badges. |
| Do teams have brand palettes? | Yes, plus recent colors per user. | Swatch groups, and recent colors in local storage. |
| Mobile and touch? | Yes. Settings pages are used on phones. | Pointer events, touch-action, a 44 by 44 pixel thumb target. |
Who consumes onChange? | Some cheap (a preview), some expensive (re-theming a whole page, saving to a server). | Two events: onChange while dragging and onChangeEnd on release. |
Functional requirements
- Pick a color by dragging in a 2D area (saturation and brightness) and a hue slider, with an optional alpha slider.
- Show the current color as a swatch, and in a text field in the chosen format.
- Accept typed or pasted colors:
#rgb,#rgba,#rrggbb,#rrggbbaa, with or without#, plusrgb(),hsl()andoklch()strings. - Sample a color from anywhere on screen with an eyedropper, where the browser supports it.
- Offer preset swatches (brand palette, document colors) and the last 8 recent colors.
- Show a contrast ratio against a given background, with WCAG AA and AAA results.
- Work fully with keyboard, touch and screen readers.
Non-functional requirements
- Smooth dragging: each pointer move updates the thumb and swatch within one frame, about 16 ms at 60 Hz, on a mid-range phone.
- No drift: picking a color, closing and reopening shows the exact same thumb positions and the exact same hex.
- Small: the core (area, sliders, field) under about 6 KB gzipped, so a label editor does not pay for a full canvas tool. For scale, react-colorful advertises about 3 KB gzipped.
- Accessible: every part reachable by Tab, operable by arrow keys, announced with a value a person understands, such as "Hue 210 degrees, blue".
- Predictable for consumers:
onChangenever fires with a half-typed, invalid value.
Out of scope
- Design tokens and theme generation for a whole product: that is the job of the design-system-rollout and dark-mode-theming guides. This picker produces one color; those systems decide where it goes.
- Gradient editors with many color stops, and canvas rendering: the figma guide covers the canvas.
- Print color (CMYK) and color management for images.
Check your understanding · Exercise 1
Which answers to scope questions change the component's design, not just its default props? Select all that apply.
What makes a color picker hard
A color picker looks like a weekend project: a gradient, a slider, a text box. Most first versions work in the demo and then break in ways that are hard to see. Five things make the problem harder than it looks, and every later chapter answers one of them.
- Many models, one color. The same blue is
#3b82f6,rgb(59 130 246),hsl(217 91% 60%)and aboutoklch(0.62 0.19 260). The square edits saturation and brightness, the hue bar edits hue, the field edits hex. Each conversion rounds, and rounding errors add up if you convert back and forth on every move. - Information that hex cannot hold. Drag the thumb to the bottom edge and the color becomes black. Black has no hue. If your state is a hex string, the hue slider jumps to 0 (red) the moment you touch black or gray, and the square under it turns red. The fix is a choice about state, not a bug fix in the slider.
- A two-dimensional control. The square sets two numbers at once. There is no ARIA role for a 2D slider, so a screen reader has no built-in way to describe it, and the keyboard needs a clear plan for which arrow moves which number.
- Live feedback at 60 frames a second. Every pointer move changes the color, the thumbs, the swatch, the text, the contrast ratio, and the consumer's preview. Some consumers are cheap, some re-theme a whole page. The component must stay smooth while not flooding them.
- Text input that fights back. A person types
#3b8on the way to#3b82f6. That partial value is also a valid 3-digit hex. If the field pushes every keystroke through as a value, the color flashes and the field reformats under the cursor.
Here is the picker this guide designs, with its parts named. Keep it in mind: the architecture later in the guide has one module per part, and all of them read and write a single shared color.
Two more forces shape the design without being visible in the picture. The first is size: a label editor on GitHub needs 20 swatches and a hex field, not a 40 KB color science library. That pushes us toward composable parts that teams import one by one.
The second force is the browser itself. The eyedropper exists only in desktop Chromium browsers, wide-gamut colors only matter on wide-gamut screens, and oklch() only became safe to ship in CSS in 2023, when Chrome and Firefox caught up with Safari. The design must degrade gracefully on every one of these.
Think first
Browsers ship input type="color". Why not use it and skip the whole design?
The native input is a fine choice for an internal tool. It is accessible, free and tiny, but it has hard limits. For years its value could only be a 7-character sRGB hex like #3b82f6. The HTML standard has since added alpha and colorspace attributes (for opacity and Display P3), but at the time of writing only Safari, from version 18.4, supports them; Chrome and Firefox still offer sRGB hex only.
Each browser and operating system draws its own popup, so you cannot add brand swatches, a contrast check, or OKLCH editing, and you cannot match your design system's look.
A strong answer mentions it first, says when it is enough, and then explains which requirements (alpha, swatches, contrast, consistent look, typed oklch()) push us to build our own.
Color models: what the square and sliders actually edit
A color model is a way to describe a color with numbers. A color space pins those numbers to real colors, so rgb(255 0 0) means one exact red. The web's default space is sRGB, the space of most screens from the last 25 years and of every hex code. Inside sRGB we can still describe a color with different models, and the model decides what a picker's controls mean.
| Model | Numbers | Good for | Weak at |
|---|---|---|---|
| RGB / hex | red, green, blue, each 0 to 255 | Storage, CSS, APIs, exact equality | Humans: nobody thinks "more green, less blue" |
| HSL | hue 0 to 360, saturation and lightness 0 to 100% | Writing CSS by hand, tweaking lightness | A picker square: lightness 100% is always white |
| HSV (also HSB) | hue, saturation, value (brightness) | The picker square: pure color in one corner | Equal steps do not look equal |
| OKLCH | lightness 0 to 1, chroma 0 to about 0.4, hue | Palettes, accessible scales, wide gamut | Many values fall outside sRGB |
HSL or HSV for the square?
Almost every picker you know (Photoshop, Figma, Chrome DevTools, react-colorful) draws an HSV square: saturation from left to right, value from bottom to top, at the hue chosen on the slider. The reason is the shape of the two models. In HSV, the top-right corner is the pure hue at full strength, the left edge fades to white and gray, and the bottom edge is black. Every corner is a distinct, useful color.
In HSL, lightness 100% is white whatever the saturation, and lightness 0% is black. An HSL square therefore has a whole top edge of white and a whole bottom edge of black, and the pure hue sits in the middle of the right edge. A wide band near the top is close to white and a wide band near the bottom is close to black, and the most saturated color is not in a corner where a person would look for it.
Decision Which color model should the 2D area edit?
What we pick: Default to an HSV square, because it is what people already know and it is cheap and exact inside sRGB. Offer an OKLCH area as a second mode for the teams that build palettes or need P3 (the canvas editor, the design tokens team). Both modes write to the same color value, so the rest of the picker does not care which one is on screen.
OKLCH: why perceptual matters
HSV and HSL treat every hue the same, but our eyes do not. hsv(60 100% 100%) (yellow) looks far brighter than hsv(240 100% 100%) (blue), even though the value is 100% in both. If a design system builds a 10-step scale for every hue by stepping HSL lightness, the yellow step 5 and the blue step 5 have very different contrast against white. That is how teams end up with a "500" shade that passes AA for one hue and fails for the next.
OKLab and its polar form OKLCH were published by Björn Ottosson in 2020 to fix this. L is perceived lightness from 0 to 1, C is chroma (how colorful), H is the hue angle. Two colors with the same L look about equally light.
CSS Color 4 added oklch() and oklab(). Safari has supported them since version 15.4 (2022), Chrome since 111 and Firefox since 113 (both 2023), and Tailwind CSS v4 defines its whole default palette in OKLCH. For a picker, OKLCH is the right model for the "build a scale" job, and it is the model that can describe colors beyond sRGB.
Converting, and where rounding happens
Conversions work in floating point. HSV to RGB is short. OKLCH to RGB takes more steps: polar to OKLab, a 3 by 3 matrix to cone responses (called LMS), a cube, a second matrix to linear-light sRGB, and finally the sRGB transfer curve (gamma). The code below shows the HSV path first; the OKLCH path follows it.
type Hsv = { h: number; s: number; v: number }; // h 0-360, s and v 0-1
type Rgb = { r: number; g: number; b: number }; // 0-1 floats
export function hsvToRgb({ h, s, v }: Hsv): Rgb {
const f = (n: number) => {
const k = (n + h / 60) % 6;
return v - v * s * Math.max(0, Math.min(k, 4 - k, 1));
};
return { r: f(5), g: f(3), b: f(1) };
}
export function rgbToHsv({ r, g, b }: Rgb, prevHue: number): Hsv {
const max = Math.max(r, g, b);
const d = max - Math.min(r, g, b);
let h = prevHue; // keep the old hue when it is undefined (gray)
if (d > 0) {
if (max === r) h = 60 * (((g - b) / d + 6) % 6);
else if (max === g) h = 60 * ((b - r) / d + 2);
else h = 60 * ((r - g) / d + 4);
}
return { h, s: max === 0 ? 0 : d / max, v: max };
}
export const toHex = ({ r, g, b }: Rgb) =>
"#" + [r, g, b].map((c) => Math.round(c * 255).toString(16).padStart(2, "0")).join("");
Notice the prevHue argument. Gray, white and black have no hue: the formula would divide by zero, and most libraries return 0. Passing the previous hue in and keeping it is the first half of the fix for the gray-hue problem in the next chapter. The second half is not converting back at all when we do not have to.
The OKLCH path uses the matrices from Ottosson's reference post, unchanged. The sRGB transfer curve is the same one the contrast chapter uses to measure luminance. A quick check that the code is right: pure red, #ff0000, must come out as about oklch(62.8% 0.2577 29.23), the value CSS tools show.
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
export function rgbToOklch({ r, g, b }: Rgb) {
const [lr, lg, lb] = [toLinear(r), toLinear(g), toLinear(b)];
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
const h = (Math.atan2(B, A) * 180) / Math.PI;
return { l: L, c: Math.hypot(A, B), h: h < 0 ? h + 360 : h };
}
// Linear-light sRGB; channels outside 0 to 1 mean "out of gamut".
export function oklchToLinearRgb({ l, c, h }: { l: number; c: number; h: number }) {
const a = c * Math.cos((h * Math.PI) / 180);
const b = c * Math.sin((h * Math.PI) / 180);
const l3 = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m3 = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s3 = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
return {
r: 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3,
g: -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3,
b: -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3,
};
}
// rgbToOklch({ r: 1, g: 0, b: 0 }) -> { l: 0.628, c: 0.2577, h: 29.23 }
// For output: inGamut = every channel in 0..1; then apply toGamma to each.
Gamut: colors the screen cannot show
A gamut is the set of colors a space or a screen can show. sRGB is the smaller one. Display P3, used by recent iPhones, Macs and many Android phones, is noticeably larger (quoted figures range from about 25% to 50%, depending on how you measure), mostly in saturated greens, reds and oranges. CSS can ask for them with color(display-p3 0 1 0) or with a high-chroma oklch(), and the (color-gamut: p3) media query, in CSS or in matchMedia("(color-gamut: p3)").matches, tells you whether the screen can show them.
OKLCH can describe colors outside both. oklch(0.9 0.3 140) is a green outside both sRGB and Display P3. When a value is out of gamut, something must bring it inside. Clipping clamps each RGB channel to the 0 to 1 range: fast, but it can shift the hue and flatten a gradient.
Gamut mapping lowers chroma in OKLCH, keeping lightness and hue, until the color fits. CSS Color 4 specifies this as a binary search on chroma that stops early when the clipped color is within a just-noticeable difference (an OKLab distance of 0.02). Browsers do not all follow it yet; Chrome and Safari have been reported to clip, so the picker should map colors itself rather than hand out-of-gamut values to CSS.
A good OKLCH area draws the gamut edge as a line, so people see where "real" colors end, and maps the stored value only when it is converted to sRGB for output. Try it below. Drag in the HSV square, then switch to the OKLCH area and push chroma past the sRGB edge. Watch the same color in four formats, the hue when saturation reaches 0, and the clipped hex next to the chroma-reduced one.
Check your understanding · Exercise 2
Which statements about color models in a picker are true? Select all that apply.
State: keep the model the user is editing
The output of the picker is a hex string, so the first idea is to store a hex string. Every part reads it, converts it to what it needs, and writes a new hex string back. The square converts hex to HSV to place its thumb, the hue slider converts hex to HSV to read hue, and so on. It is simple, and it breaks in two ways that every user notices within a minute.
The gray-hue problem
Pick a blue: hue 210. Now drag the thumb to the left edge of the square, where saturation is 0, and the color becomes a gray like #8c8c8c. Store that as hex, convert it back to HSV, and the hue is undefined; the formula returns 0. The hue slider jumps to red, the square's background turns red, and when you drag right again you get a red instead of blue.
The same happens at the bottom edge (black) and the top-left corner (white). Hue was information the user gave us, and the hex string had no place to keep it. Near-grays have the same problem in a smaller way. hsv(210 3% 8%) rounds to #141414, a pure gray, so even a tiny saturation is lost and the hue goes with it.
Round-trip drift
The second break is drift. Hex has 256 steps per channel. A dark color like hsv(200 50% 10%) becomes #0d151a, and reading that back gives hsv(203.1 50% 10.2%). The thumb moves 3 degrees on the hue bar although the user only clicked.
Store CSS-style integers instead and it gets worse: there are 16.7 million hex colors but only 3.67 million integer HSL triples (360 by 101 by 101), so #3b82f6 becomes hsl(217 91% 60%) and comes back as #3c83f6. A designer pastes a brand color and gets a different one back.
Decision What should the picker keep as its source of truth?
What we pick: Keep the model the user is editing as the source of truth, in floats, with alpha as its own channel. The square writes s and v, the hue slider writes h, and nothing else is touched. Hex, RGB and the other formats are derived values, computed when we render text or call onChange. This is close to what React Aria does: its Color object knows its color space, and its area and sliders edit channels in that space.
When a new value comes from outside
A controlled picker gets values from its parent. When the parent passes back the same hex we just emitted, we must not convert it to HSV and overwrite our state, or the hue would jump at gray after all.
The rule: compare the incoming value with the hex of our current state. If they match, ignore it; our richer state already describes it. If they differ (the parent reset the color, or another picker changed it), convert and keep the old hue if the new color has none.
type PickerColor = { space: "hsv"; h: number; s: number; v: number; alpha: number };
function syncFromProp(state: PickerColor, incoming: string): PickerColor {
const parsed = parseCssColor(incoming); // sRGB floats + alpha, or null
if (!parsed) return state; // invalid prop: keep what we have, warn in dev
if (formatHex(state) === formatHex(parsed)) return state; // our own echo
const hsv = rgbToHsv(parsed, state.h); // gray keeps the old hue
return { space: "hsv", ...hsv, alpha: parsed.alpha };
}
Alpha
Alpha is opacity from 0 (fully transparent) to 1. Keep it as a separate float, never baked into the RGB numbers. Output it as 8-digit hex (#3b82f680 is about 50%), or as rgb(59 130 246 / 0.5). Hide the alpha slider and drop alpha from output when the consumer says alpha={false}: a label color with 50% alpha would look different on every background.
Check your understanding · Exercise 3
A picker stores { h, s, v, alpha } and derives hex. Which bugs does that prevent? Select all that apply.
The 2D area and the sliders: pointer, keyboard, screen reader
The square is the heart of the picker and the hardest part to get right for everyone who is not using a mouse. This chapter builds it in three layers: drawing, pointer input, and keyboard plus screen reader. The hue and alpha sliders reuse the same pieces in one dimension.
Drawing the square without a canvas
An HSV square at a fixed hue is three layers. The bottom layer is the pure hue, hsl(h 100% 50%). On top sits a gradient from white on the left to transparent on the right: that is saturation. On top of that, a gradient from transparent at the top to black at the bottom: that is value.
Two linear-gradient layers and a background color give the exact HSV square, because HSV is defined as exactly this mix: white blended in by 1 - s, then everything scaled by v. No canvas, no pixels to compute, and the browser draws it.
.color-area {
--hue: 217; /* set from JS when hue changes */
background:
linear-gradient(to top, #000, transparent),
linear-gradient(to right, #fff, transparent),
hsl(var(--hue) 100% 50%);
touch-action: none; /* we handle every touch move ourselves */
position: relative;
}
.color-area-thumb {
--s: 0.76; /* set from JS on every move */
--v: 0.96;
position: absolute;
left: calc(var(--s) * 100%);
top: calc((1 - var(--v)) * 100%);
translate: -50% -50%;
}
The OKLCH area cannot be drawn this way: two stacked gradients cannot describe a lightness by chroma grid, and the gamut edge is a curve. That mode draws on a canvas (a 256 by 256 image is 65,536 conversions, a few milliseconds), only when hue changes, and draws the sRGB and P3 edges as lines on top.
Pointer input
Use pointer events, which cover mouse, pen and touch with one code path. On pointerdown, call setPointerCapture so the area keeps getting pointermove events even when the finger slides off the edge; without it, a fast drag past the border stops updating. Set touch-action: none on the area so a vertical drag on a phone moves the thumb instead of scrolling the page. The image-cropper guide covers capture and gestures in more depth; a picker needs only this simple single-pointer case.
const clamp = (n: number) => Math.min(1, Math.max(0, n));
function pointToSv(area: HTMLElement, e: PointerEvent) {
const box = area.getBoundingClientRect();
return {
s: clamp((e.clientX - box.left) / box.width),
v: clamp(1 - (e.clientY - box.top) / box.height),
};
}
area.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return; // left button, touch or pen only
area.setPointerCapture(e.pointerId);
area.focus(); // so arrow keys work right after a click
update(pointToSv(area, e)); // jump the thumb to the click point
});
area.addEventListener("pointermove", (e) => {
if (area.hasPointerCapture(e.pointerId)) update(pointToSv(area, e));
});
// Fires after pointerup and after pointercancel, so this is the one place a drag ends.
area.addEventListener("lostpointercapture", () => commit()); // fires onChangeEnd once
Clamping matters: a captured pointer can be far outside the square, and without clamp saturation would go to 1.4 or minus 0.2. End the drag on lostpointercapture rather than on pointerup. The browser releases capture after a normal pointerup and also after a pointercancel (for example, when a system gesture takes over a touch), so this one event covers both. Listening to both events would call commit twice and fire onChangeEnd twice.
onChange carries the live preview; onChangeEnd is the only place that saves. The drag ends on lostpointercapture, which follows both pointerup and a cancelled touch, so the end event fires exactly once.Keyboard: one step, big step, edges
Every channel needs a small step for precision and a big step for speed. For the square: Left and Right change saturation by 1%, Up and Down change value by 1%, and holding Shift makes it 10%. Page Up and Page Down change value by 10%, Home and End jump saturation to 0 and 100%.
The hue slider steps by 1 degree, Shift by 10 degrees, and wraps nowhere: 360 is the end, because wrapping would surprise a person counting key presses. Each key press is a complete change, so it fires both onChange and onChangeEnd.
Making a 2D control speak
ARIA has role="slider" for one value and nothing for two. The WAI-ARIA slider pattern is the reference for each axis, and the aria-patterns lesson covers it. The question is how to expose the second axis.
Decision How should the 2D area appear to keyboard and screen reader users?
What we pick: Use two visually hidden range inputs, the approach React Aria's useColorArea takes (its docs describe xInputProps and yInputProps as props for visually hidden range inputs, each announced as a "2D slider"). Each axis then behaves like any other slider, on desktop and with VoiceOver or TalkBack swipes. On desktop, keep one tab stop: the saturation input is in the tab order, and pressing Up or Down moves focus to the value input, so the area feels like one 2D control. On touch devices, expose both inputs so swipe gestures can reach each axis.
The value text is what people hear, so make it human. A hue slider announcing "210" is a number without meaning. Announce "Hue 210 degrees, blue" instead, using a small table of hue ranges for the name (see the contrast chapter). For the square, announce "Saturation 76%, brightness 96%, vivid blue", and for alpha, "Opacity 50%".
The star-rating guide walks through the same idea for its slider-like input. Keep the visible focus ring on the thumb, with at least 3 to 1 contrast against both the light and dark parts of the gradient: a white ring with a dark outline works everywhere.
Check your understanding · Exercise 4
A tester reports bugs in the square. Which fixes are right? Select all that apply.
Typing and pasting colors without fighting the user
Designers rarely drag to a brand color; they paste it. The text field is often the most used part of the picker, and the easiest to get wrong. The naive version is a controlled input whose value is the formatted hex of the current color, and whose onChange parses each keystroke and updates the color.
Watch what happens when someone types #3b82f6 over #ffffff. They select all and type #: not valid, so the parse fails and the input snaps back to #ffffff. They cannot type at all. Fix that by keeping invalid text, and the next problem appears: #3b8 is a valid 3-digit hex (#33bb88), so the swatch flashes green, the parent gets an onChange with a color nobody chose, and if the field reformats to the 6-digit form, the cursor jumps to the end mid-word.
Decision When should text in the field become the picker's color?
What we pick: Commit on blur and Enter, with a local draft while the field is focused. Pasting is the main case, and a paste is a complete value, so we also commit on paste when the pasted text parses. Skip live preview of typed 6-digit values at first: it adds an API concept for little gain. While the draft is invalid, mark the field with aria-invalid="true" and a short message linked by aria-describedby, but do not block typing.
What to accept
Be generous on input and strict on output. People paste hex with or without #, in upper or lower case, with spaces around it, in 3, 4, 6 or 8 digits. They paste rgb(59, 130, 246) (the old comma form) and rgb(59 130 246 / 50%) (the modern space form), hsl(217 91% 60%) and oklch(62% 0.19 260). A forgiving parser accepts all of these and outputs one canonical format.
const HEX = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
export function parseHex(text: string) {
const m = HEX.exec(text.trim());
if (!m) return null;
let digits = m[1];
if (digits.length <= 4) digits = [...digits].map((d) => d + d).join("");
const n = (i: number) => parseInt(digits.slice(i, i + 2), 16) / 255;
return {
r: n(0), g: n(2), b: n(4),
alpha: digits.length === 8 ? n(6) : 1,
};
}
// parseHex("3b8") -> #33bb88
// parseHex("#3B82F680") -> blue at 50% alpha
For the function forms, the browser can help with validation: CSS.supports("color", text) returns true for any color the browser understands, including named colors like rebeccapurple and newer syntax. It cannot hand you the numbers, so you still need a parser for rgb(), hsl() and oklch(), or a small library. Using CSS.supports as a first check keeps your parser from accepting things the browser rejects.
Treat the parsed values as untrusted input: clamp channels to their ranges and reject NaN, because a pasted rgb(300 0 0) is valid CSS (the browser clamps it) but must never reach our state as 300.
Add a format switcher next to the field (hex, RGB, HSL, OKLCH), like the one in Chrome DevTools. It changes only how the field displays the current color; the stored state does not move. And when the field shows a format that cannot hold the color, say so: an out-of-sRGB OKLCH color shown as hex gets a small "clipped" marker, the way Chrome DevTools flags clipped values with a warning icon.
Check your understanding · Exercise 5
A user selects the hex field, which shows #ffffff, and types 3b82f6. With the blur-and-Enter design, which things happen? Select all that apply.
Eyedropper, presets and recent colors
The fastest way to pick a color is not to pick it at all: sample it from the screen, or click one you used before. These features are small in code and large in daily use, and each has one trap.
The EyeDropper API
The EyeDropper API turns the cursor into a magnifier that can sample any pixel on the screen, even outside the browser window. You call new EyeDropper().open(), and the promise resolves with { sRGBHex: "#aabbcc" }. It works only on secure (HTTPS) pages in desktop Chromium browsers (Chrome and Edge since version 95), and at the time of writing not in Firefox, Safari or Chrome on Android.
So the first rule is feature detection: render the eyedropper button only when "EyeDropper" in window is true. A disabled button that never works is worse than no button.
The second rule is user activation. The browser only opens the eyedropper during a transient user activation, the short window after a real gesture such as a click, so a page cannot silently read pixels from your screen. If you call open() after an await that took too long, or from a timer, it rejects with NotAllowedError. Call it directly in the click handler.
If the user presses Escape, the promise rejects with AbortError: that is a normal cancel, not an error to report. Pass an AbortSignal so closing the popover also ends eyedropper mode. A second open() while one is already running rejects with InvalidStateError, so disable the button while sampling.
// Run in an effect after mount, not during render, so server and client HTML match.
const canSample = () => "EyeDropper" in window;
async function onSampleClick(signal: AbortSignal) {
try {
const { sRGBHex } = await new EyeDropper().open({ signal });
const rgb = parseHex(sRGBHex);
if (rgb) commitColor(rgb); // fires onChange and onChangeEnd once
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
reportError(err); // NotAllowedError, InvalidStateError, OperationError
} finally {
sampleButton.focus(); // focus returns where the user started
}
}
Note what the API returns: an sRGB hex. On a Display P3 screen, a sampled wide-gamut pixel arrives already squeezed into sRGB, so a P3-aware picker cannot sample P3 colors this way.
open() directly in the click handler, or the browser rejects it with NotAllowedError. An AbortError is the user saying no: restore focus, change nothing, report nothing.Presets, document colors and recents
Swatches come in three groups with three owners. Presets are the brand palette: they come from the design tokens (the design-system-rollout guide covers where tokens live) and the consumer passes them in. Document colors are colors already used in the current file, like the document colors Figma lists in its picker; only the consumer knows them, so they are a prop too. Recent colors belong to the person, across every picker in the product, so the picker can own them.
| Group | Who owns it | Where it lives | Size |
|---|---|---|---|
| Presets (brand) | The design system | A swatches prop, from tokens | 10 to 40 colors |
| Document colors | The consumer app | A swatches group computed from the document | Up to about 20, most used first |
| Recent colors | The user | localStorage under one key, shared by all pickers | Last 8, deduplicated |
Recent colors should be written on onChangeEnd, not on onChange, or a single drag would fill the list with 8 nearly identical blues. Store the canonical hex, deduplicate, put the newest first, and cap the list. Wrap the read in a try: localStorage can throw in some privacy modes, and the picker must still work without recents. Listen to the storage event if two pickers can be open in different tabs.
A swatch is a button with a name, never a colored square alone: aria-label="Brand blue, #3b82f6", and aria-pressed="true" on the one matching the current color. Group them with a heading per group. For a long palette, the grid pattern with arrow keys (one tab stop for the whole grid) saves keyboard users 40 Tab presses; the keyboard-navigation lesson covers roving tab index.
Check your understanding · Exercise 6
Which eyedropper and swatch choices are correct? Select all that apply.
Contrast checking and color names
Many picked colors end up as text or as the background behind text. A picker that shows the contrast ratio while you drag stops bad choices at the moment they are made, instead of in an accessibility audit months later. The dark-mode-theming guide checks contrast for every token pair across themes; here the job is smaller and live: one foreground, one or a few backgrounds, updated on every move.
The WCAG 2 contrast ratio
WCAG 2 defines relative luminance: undo the sRGB gamma on each channel, then weight them, 0.2126 R + 0.7152 G + 0.0722 B. Green counts ten times more than blue because our eyes are far more sensitive to it. The contrast ratio of two colors is (lighter + 0.05) / (darker + 0.05), from 1:1 (same color) to 21:1 (black on white).
const lin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
function luminance({ r, g, b }: Rgb) {
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
export function contrastRatio(fg: Rgb, bg: Rgb) {
const a = luminance(fg);
const b = luminance(bg);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
// A translucent color shows the background through it.
export const over = (fg: Rgb, alpha: number, bg: Rgb): Rgb => ({
r: fg.r * alpha + bg.r * (1 - alpha),
g: fg.g * alpha + bg.g * (1 - alpha),
b: fg.b * alpha + bg.b * (1 - alpha),
});
| Use of the color | AA needs | AAA needs | WCAG criterion |
|---|---|---|---|
| Normal text | 4.5:1 | 7:1 | 1.4.3 and 1.4.6 |
| Large text (24px, or 18.66px bold) | 3:1 | 4.5:1 | 1.4.3 and 1.4.6 |
| Icons, borders, focus rings, chart lines | 3:1 | no AAA level | 1.4.11 |
Take our blue, #3b82f6, as text on white: the ratio is 3.678, shown as 3.67:1. It fails AA for body text, passes for large text, and passes as a button border. The picker should say exactly that, not just "fail".
So the contrastWith prop takes the background and the intended use, and the badges show the result for that use. Round the ratio down to two decimals: rounding 4.497 up to 4.5 would show a pass that is not one. A helpful extra is a line on the square marking where the ratio crosses 4.5:1, so the user sees which way to drag. Chrome DevTools draws such AA and AAA lines on its picker's square for text colors.
Two traps. First, alpha: a 50% black on white is a mid gray, so composite the color over its background (the over function) before measuring. At 50% opacity, our blue on white measures about 1.84:1, not 3.67:1.
Second, the newer method. APCA (Accessible Perceptual Contrast Algorithm) has been proposed as the contrast method for WCAG 3. It accounts for font size and weight and treats dark-on-light differently from light-on-dark. WCAG 3 is still a working draft and has not settled on a contrast method, so ship WCAG 2 as the rule and, if your team wants it, show APCA as extra information only.
Naming colors for people who can't see them
"Hue 210 degrees" means little to most people, and nothing to someone who has never seen color. A name helps: "dark blue", "light gray", "vivid orange". The visual-accessibility lesson explains why color must never be the only signal; a name turns the value into words.
| Approach | Example output | Size | Trade-off |
|---|---|---|---|
| Hue ranges plus lightness and chroma words | "dark vivid blue" | under 1 KB | Predictable, translatable, a bit coarse |
| Nearest CSS named color (148 of them) | "royalblue" | about 2 KB | Names like rebeccapurple and papayawhip confuse more than help |
| Large naming list (1,000+ names) | "Dodger blue" | 20 KB or more | Rich, but heavy and hard to translate |
Pick the first. Measure hue, lightness and chroma in OKLCH (perceptual, so "dark" means the same for every hue), map hue to about 10 named ranges (red, orange, yellow, green, teal, blue, purple, pink, plus brown for dark oranges), add "light" or "dark" from L, "pale" or "vivid" from C, and "gray" when chroma is near 0. Each word is a translation key, so the names work in every locale the product supports. Use the name in aria-valuetext, on swatch labels, and in a visible caption under the swatch.
Check your understanding · Exercise 7
A user picks #3b82f6 for button text on a white card. Which results should the picker show? Select all that apply.
Architecture, API and performance: a color core and composable parts
We now know the parts. The architecture question is how to package them for teams with very different needs: GitHub-style labels want swatches and a hex field; the canvas editor wants everything, plus OKLCH and P3. This chapter covers how the parts are packaged, the API they share with consumers, and how they stay fast during a drag.
Decision How should the picker be packaged for many teams?
What we pick: Composable parts over a shared state, with a pure color core underneath and a default ColorPickerPopover that composes the common layout. This is the shape React Aria chose, and it matches the component-api-design lesson's advice: composition over configuration. Hooks sit inside the parts, so a team with an unusual need can still drop down a level.
| Module | Responsibility | Why the boundary is here |
|---|---|---|
| Color core | Parse, convert, format, gamut map, contrast, name. Pure functions. | Testable without a DOM; shared with server code and token tools; OKLCH and naming are separate entry points so they can be split out. |
| Picker state (provider) | Holds the editing color, mode, controlled or uncontrolled logic, and the two events. | One owner for the value, so parts never disagree. |
| Area and sliders | Pointer, keyboard and hidden inputs; write channels. | Same interaction code for every channel. |
| Field | Draft text, parsing, format switch. | Its draft must not leak into shared state. |
| Swatches and recents store | Preset groups from props; recents in localStorage. | Recents are per user, not per picker. |
| Eyedropper button | Feature detection, activation, abort. | Rendered only where supported. |
| Contrast badge | Ratio and AA or AAA results for contrastWith. | Optional; costs nothing when absent. |
Follow one drag through the system. A pointer move reaches the area, which computes new s and v and writes them to the provider. The provider stores them, the parts re-render from the new state (the thumb moves, the hue stays), and the provider calls the consumer's onChange with a normalized color object. When the pointer is released, onChangeEnd fires once and the recents store records the final hex; the diagram below shows what crosses each arrow.
onChange every frame to a cheap preview, onChangeEnd once to recents and to the one PATCH that saves.Check your understanding · Exercise 8
Why does the color core contain no React code? Select all that apply.
Data model and component API
For a design system component, the API matters as much as the pixels. Consumers think in strings ("save #3b82f6 to the label"); the picker thinks in channels. The API has to accept the strings, expose the channels, and never leak internal state.
All the data is client state, and almost all of it is local to one picker. Only recents are persisted, and only the consumer talks to a server. The table shows where each piece lives.
| Data | Lives in | Lifetime |
|---|---|---|
Editing color (h, s, v, alpha or l, c, h, alpha) | Picker provider state | While the picker is mounted |
| Field draft text and error | Field component state | While the field is focused |
| Display format (hex, rgb, hsl, oklch) | Provider, seeded from the format prop | Per picker; optionally saved by the consumer |
| Recent colors | localStorage, one key | Across sessions, per browser |
| Presets, document colors, contrast background | Props | Owned by the consumer |
| The saved color | The consumer's store or server | Forever; the picker never saves it |
export interface Color {
readonly space: "hsv" | "oklch"; // the model being edited
readonly alpha: number; // 0 to 1
channel(name: ColorChannel): number; // "hue", "saturation", "lightness"...
withChannel(name: ColorChannel, value: number): Color;
toString(format?: "hex" | "hexa" | "rgb" | "hsl" | "oklch"): string;
toRgb(): { r: number; g: number; b: number }; // sRGB floats, gamut mapped
inGamut(gamut: "srgb" | "p3"): boolean;
equals(other: Color): boolean; // compares output, not objects
}
export function parseColor(input: string): Color; // throws on invalid input
type ColorPickerProps = {
value?: string | Color; // controlled
defaultValue?: string | Color; // uncontrolled
onChange?: (color: Color) => void; // every move, up to once per frame
onChangeEnd?: (color: Color) => void; // release, Enter, blur, swatch click
format?: "hex" | "rgb" | "hsl" | "oklch"; // what the field shows
alpha?: boolean; // default true; false hides alpha and strips it
gamut?: "srgb" | "p3"; // default "srgb"
contrastWith?: { background: string; use: "text" | "large-text" | "ui" };
swatches?: { label: string; colors: string[] }[];
recentKey?: string | null; // localStorage key; null turns recents off
isDisabled?: boolean;
children?: React.ReactNode; // the parts; default layout if absent
};
// Usage: a GitHub-style label picker
<ColorPicker value={label.color} onChangeEnd={(c) => save(c.toString("hex"))}>
<SwatchGroup /> <ColorField aria-label="Label color" />
</ColorPicker>
A few rules make this API predictable. Values in, Color out: value accepts any string we can parse or a Color; events always hand back a Color, and the consumer calls toString("hex") to store it. Giving back an object instead of a string lets a consumer ask for any format without a second parser, and lets the picker keep the hue of a gray.
Controlled or uncontrolled, never both: like a native input, pass value with onChange, or defaultValue alone, and warn in development when a picker switches between them. Two events with clear contracts: onChange fires for every visible change and must be cheap; onChangeEnd fires once per completed gesture, key press, swatch click, eyedropper sample or committed field, which is where saving and recents belong.
Check your understanding · Exercise 9
A team re-themes the whole app (about 400 CSS variables) when a brand color changes, and saves it to the server. Which wiring is right? Select all that apply.
Performance: 60 frames a second while dragging
A drag on the square is a stream of pointer events, each wanting to move the thumb, recolor the swatch, rewrite the field text, recompute contrast, rename the color, and call the consumer. At 60 Hz the browser has about 16.7 ms per frame for all of it plus style, layout and paint. The runtime-performance lesson covers frames in general; here is what matters for a picker.
Pointer hardware often reports faster than the screen refreshes: a touch screen at 60 to 120 Hz, a mouse at 100 Hz or far more. Chrome, since version 60, already delivers pointermove at most about once per frame and keeps the in-between samples in getCoalescedEvents(). Not every browser and setup is guaranteed to do the same, so the picker should make one update per frame its own rule rather than rely on it.
- Redraw the gradient only when hue changes. The square's background depends only on hue. Moving the thumb sets two CSS variables (
--s,--v) on the thumb; the gradient layers do not change, so only the small thumb needs new layout and paint. In OKLCH mode, the canvas is redrawn on hue change only, and the work (65,536 conversions) can move to a worker withOffscreenCanvasif it ever costs more than a few milliseconds on low-end phones. - Read layout once per drag. Call
getBoundingClientRectonpointerdownand reuse the box; the area does not resize mid-drag (measure again if the page can scroll during one). Reading layout after writing styles in the same frame forces a synchronous layout. - Batch to one update per frame. Store the latest pointer position and apply it in a
requestAnimationFramecallback. However many events arrive in one frame, they become one state update and one render. - Keep
onChangework cheap. Contrast math and naming take microseconds; the consumer's handler is the unknown. That is what the next decision is about.
Decision How often should consumers hear about changes during a drag?
What we pick: Once per animation frame, plus onChangeEnd. The picker's own visuals update every frame. A consumer whose preview is truly heavy can throttle in its own handler, because only it knows its cost; the debounce-throttle lesson shows how. The picker's job is to never send more than one change per frame and to mark the end clearly.
Bundle size is the other budget. The HSV picker with hex parsing fits in a few kilobytes. The OKLCH math, gamut mapping and the naming tables are separate entry points, and the OKLCH area is loaded with a dynamic import() only when someone switches to that mode. Recents and the eyedropper are tiny, but the eyedropper button is not rendered at all where EyeDropper is missing.
Accessibility settings belong in the same pass. Under prefers-reduced-motion: reduce, the thumb jumps without a transition. In Windows forced colors mode, test that the thumb and focus ring stay visible, using forced-color-adjust only where the gradient itself must keep its colors. And the picker must never be the only way to set a color: the text field gives everyone, including people who cannot drag or see the gradient, an exact path.
Check your understanding · Exercise 10
Profiling shows dropped frames while dragging on a mid-range phone. Which changes help? Select all that apply.
Running it in the interview
A color picker interview is a component interview, so the interviewer wants to see API taste, interaction detail and accessibility, with enough color knowledge to avoid the classic bugs. You will not have time for everything in this guide. Spend it like this.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask the scope questions: alpha, formats, P3, contrast, swatches, who consumes changes. | "Each answer changes the model or the API, so let me ask before drawing." |
| 5 to 10 | Draw the wireframe: area, hue and alpha sliders, field, swatches. | "HSV square, because every corner is useful, unlike HSL." |
| 10 to 18 | State: store the edited model in floats, derive hex. | "Hex loses hue at gray and drifts; the thumb position is the state." |
| 18 to 28 | Area interaction: pointer capture, clamping, keyboard steps, two hidden sliders. | "There is no 2D slider role, so I expose two native ones." |
| 28 to 36 | API: parts over a provider, value and Color, onChange vs onChangeEnd. | "Cheap preview every frame, expensive commit once." |
| 36 to 45 | Pick one or two deep dives the interviewer cares about: text parsing, contrast, OKLCH and gamut, performance. | "Which would you like me to go deeper on?" |
Draw the wireframe first and the architecture second. The wireframe gives both of you shared names for the parts, and the gray-hue bug is easiest to explain by pointing at the left edge of the square. If you know only one color fact deeply, make it that one: it shows you have built or debugged a real picker.
Think first
Follow-up: "Our canvas needs Display P3. What changes?"
The value can no longer be a hex string, so stored values become oklch() or color(display-p3 ...) strings. The area switches to OKLCH and draws both the sRGB and P3 edges, so people see when a color will look different on an older screen. Output for sRGB consumers is gamut mapped by lowering chroma.
Show a small warning when the color is outside sRGB, check @media (color-gamut: p3) before promising what people will see, and note that the eyedropper returns sRGB only.
Think first
Follow-up: "Two pickers on the page edit the same theme color. How do they stay in sync?"
Both are controlled by the same value in the parent. When picker A emits onChange, the parent passes the new hex to both. Picker A recognizes its own echo and keeps its hue; picker B sees a different value, converts it, and keeps its old hue only if the new color is gray.
The parent should not hold HSV; hex or a Color object is enough. Recents update once, on onChangeEnd, so the list does not get two entries.
Check your understanding · Exercise 11
You have 5 minutes left and the interviewer asks what you would test. Which answers show the most depth? Select all that apply.
Summary
- Store the model the user is editing (HSV, or OKLCH in that mode) as floats with a separate alpha, and derive hex for output. Hex as state loses hue at gray and drifts on every round trip.
- Use an HSV square by default: every corner is a useful color and two CSS gradients draw it exactly. Offer OKLCH for perceptual scales and Display P3, with the gamut edge drawn and chroma reduced on output.
- There is no 2D slider role: expose the area as two visually hidden native range inputs with human value text like "Saturation 76%, brightness 96%, vivid blue".
- Pointer events with
setPointerCapture, clamping andtouch-action: nonemake dragging work for mouse, pen and touch; arrow keys step 1%, Shift and Page keys step 10%. - The text field keeps a local draft and commits on blur, Enter or a valid paste, so half-typed values never reach consumers.
- Feature-detect the EyeDropper API, call it from the click, and treat
AbortErroras a normal cancel. - Show the WCAG 2 contrast ratio live for the intended use, composite alpha first, and never round up past a threshold. Treat APCA as extra information while WCAG 3 is a draft.
- Ship composable parts over a shared state and a pure color core, with
onChangeat most once per frame andonChangeEndonce per completed choice.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- CSS Color Module Level 4 The spec for
oklch(),color(display-p3 ...)and gamut mapping. - A perceptual color space for image processing (OKLab) Björn Ottosson's original post, with the conversion matrices.
- OKLCH in CSS: why we moved from RGB and HSL A practical case for OKLCH palettes and wide gamut.
- oklch() on MDN Syntax and browser support.
- EyeDropper API on MDN Feature detection, user activation and errors.
- Add wide gamut P3 and alpha transparency to your color picker in HTML The
alphaandcolorspaceattributes of the native color input, first shipped in Safari 18.4. - Aligned input events Why Chrome delivers
pointermoveonce per frame, andgetCoalescedEvents()for the rest. - WAI-ARIA APG: Slider pattern Keys, roles and
aria-valuetextfor each axis and slider. - Understanding WCAG 2.2: Contrast (Minimum) The ratio, the thresholds and what counts as large text.
- React Aria ColorPicker A composable, accessible picker with ColorArea, ColorSlider and ColorField parts.
- react-colorful A tiny HSV picker; a good read for pointer and keyboard handling.
- Element.setPointerCapture() on MDN Keeping pointer events during a drag.
Concepts behind it
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Design systemsA shared set of design tokens, accessible components, patterns, docs, and rules that many teams build on, so a company ships one consistent product instead of many slightly different ones, and can change its look in one place.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.