cf.completefrontend
My progress
SYSTEM DESIGN · #35

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.

QuestionAnswer we assumeWhat 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 #, plus rgb(), hsl() and oklch() 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: onChange never 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.

  1. Many models, one color. The same blue is #3b82f6, rgb(59 130 246), hsl(217 91% 60%) and about oklch(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.
  2. 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.
  3. 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.
  4. 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.
  5. Text input that fights back. A person types #3b8 on 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.

Wireframe of the color picker popover. On the left, the ColorArea square with its thumb near the top right, then a hue slider with its thumb at 217 degrees and an alpha slider with its thumb at the far right. On the right, the current color swatch, an eyedropper button marked Chromium only, the ColorField showing #3b82f6 with a HEX format switch, and a contrast note reading vs #ffffff 3.67 to 1, AA text fail, large pass. At the bottom, a SwatchGroup with a row of 8 brand swatches, the fourth one selected, and a row of 6 recent colors.ColorPicker popoverColorArea: s by vhuealphanoweyedropperChromium only#3b82f6HEXColorField + formatvs #ffffff: 3.67:1AA text: fail, large: passBrandRecentSwatchGroup
Seven parts, one shared color. The area, sliders, field, swatches and eyedropper all write to it; the swatch, field and contrast badge all read from it.

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?

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.

ModelNumbersGood forWeak at
RGB / hexred, green, blue, each 0 to 255Storage, CSS, APIs, exact equalityHumans: nobody thinks "more green, less blue"
HSLhue 0 to 360, saturation and lightness 0 to 100%Writing CSS by hand, tweaking lightnessA picker square: lightness 100% is always white
HSV (also HSB)hue, saturation, value (brightness)The picker square: pure color in one cornerEqual steps do not look equal
OKLCHlightness 0 to 1, chroma 0 to about 0.4, huePalettes, accessible scales, wide gamutMany 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.

Nine samples from an HSV square and an HSL square at hue 210: saturation 0, 50 and 100 percent from left to right, and value or lightness 100, 50 and 0 percent from top to bottom. HSV top row: #ffffff, #80bfff, #0080ff, the pure hue in the top right corner. HSV middle row: #808080, #406080, #004080. HSV bottom row: three blacks. HSL top row: three whites. HSL middle row: #808080, #4080bf, #0080ff, the pure hue in the middle of the right edge. HSL bottom row: three blacks. HSV has one repeated row, HSL has two.HSV: y is value#ffffff#80bfff#0080ff#808080#406080#004080#000000#000000#000000saturation 0 to 100%only the bottom row repeatsHSL: y is lightness#ffffff#ffffff#ffffff#808080#4080bf#0080ff#000000#000000#000000saturation 0 to 100%top and bottom rows repeat
Same hue, same grid. In HSV the pure hue sits in a corner and only the black edge repeats. In HSL both the top and bottom edges collapse to one color each, and wide bands next to them are near-white and near-black.

Decision Which color model should the 2D area edit?

HSL square (saturation by lightness)

x is saturation, y is lightness, at the current hue.

The same numbers as CSS hsl(), so no extra conversion for display.

Pros

  • Matches what CSS authors type.
  • Simple conversion to RGB.

Cons

  • Top and bottom edges are flat white and black: wasted space.
  • The pure hue is mid-edge, not in a corner.

HSV square (saturation by value)

x is saturation, y is value, at the current hue.

Drawn as two stacked CSS gradients over a hue background.

Pros

  • The layout people expect from every design tool.
  • Every point is a distinct color; cheap to draw with gradients.
  • Conversion to RGB is a few lines.

Cons

  • Not perceptual: a yellow and a blue at the same value look very different in brightness.
  • Cannot reach colors outside sRGB.

OKLCH area (chroma by lightness)

x is chroma, y is lightness, at the current hue.

Drawn per pixel on a canvas, with the sRGB or P3 edge marked.

Pros

  • Perceptual: equal lightness steps look equal across hues.
  • Reaches Display P3 colors; ideal for palette work.

Cons

  • Irregular shape: much of the rectangle is outside any screen's gamut.
  • Needs canvas drawing and gamut checks, more code.

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.

ts
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("");
HSV to RGB and back, in floats. Round only when you produce a string.

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.

ts
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.
sRGB to OKLCH and back, with Björn Ottosson's OKLab matrices. The reverse result can fall outside 0 to 1: that is the gamut problem.

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.

AreaState
HSV square with the thumb at saturation 76% and value 96%, hue 217. The color is #3b82f6, rgb(59 130 246), hsl 217 91% 60%, oklch 0.62 0.188 260. In sRGB: yes. In Display P3: yes.saturationhex #3b82f6rgb(59 130 246)hsl(217 91% 60%)oklch(0.62 0.188 260)hue in state: 217.2state: h, s, v floatsround trips: 0
Drag in the square or use the sliders. Then drag to the left edge, where saturation is 0, and watch the hue.

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.

Two rows comparing state shapes at value 70 percent. Top row, store hex: a blue at hue 210 and saturation 60 percent is dragged to saturation 0 and stored as #b3b3b3; reading it back gives hue 0 and saturation 0, so dragging right again gives red, not blue. Bottom row, store h, s, v: the same drag stores hue 210 and saturation 0, the output is still #b3b3b3, and dragging right again gives blue.store hex: drag to s = 0, read the hex backh 210, s 60%#b3b3b3h 0, s 0%drag right again: red, not bluestore h, s, v: drag to s = 0h 210, s 60%h 210, s 0%output #b3b3b3drag right again: blue
Both rows output the same gray. Only the second remembers the hue the user chose, because hue has its own field in state instead of being recomputed from hex.

Decision What should the picker keep as its source of truth?

A hex string

Store #rrggbb (or #rrggbbaa).

Every part converts on read and writes hex on change.

Pros

  • Smallest possible state, easy to serialize.
  • Exactly what most consumers want.

Cons

  • Loses hue at gray, white and black: the gray-hue jump.
  • Drifts on every round trip through 8-bit channels.

RGB floats

Store { r, g, b, alpha } as 0 to 1 floats.

Convert to HSV for the square on every render.

Pros

  • No 8-bit rounding.
  • One canonical form for comparisons.

Cons

  • Still has no hue for grays: the jump remains.
  • Every render converts, so tiny float errors can still nudge thumbs.

The model the user is editing, with floats

Store { space: "hsv", h, s, v, alpha }, or { space: "oklch", l, c, h, alpha } in OKLCH mode.

Controls write their own channels directly; hex is derived for output.

Pros

  • Hue survives gray: the slider stays put.
  • No drift: the thumb position is the state itself.
  • Mode switch converts once, not on every move.

Cons

  • Two shapes of state to handle.
  • Equality checks must compare converted values, not objects.

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.

ts
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 };
}
Accept outside values without losing hue or causing drift.

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.

css
.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 whole HSV square. Only --hue changes when the hue slider moves.

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.

ts
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
Map a pointer to saturation and value, clamped to the square.

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.

Sequence diagram of a drag in the color area, with four lifelines: User, ColorArea, Picker state and Consumer. The user presses the pointer; ColorArea captures the pointer and writes s and v to the picker state, which re-renders the thumb. Several pointer moves arrive before the next frame; ColorArea writes s and v once in a requestAnimationFrame callback, and the picker state calls onChange on the consumer once. The user releases the pointer; ColorArea commits, and the picker state calls onChangeEnd, where the consumer saves and recents are updated.UserColorAreaPicker stateConsumerpointerdowncapture, set s, vrender thumbpointermove xNrAF: write onceonChange(color)pointerupcommitonChangeEnd: save
Many moves, one update per frame, one end. 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?

One focusable element, no role

The area is a div with tabindex="0" and key handlers.

Value is described in a live region.

Pros

  • Least markup.

Cons

  • Screen readers announce nothing useful on focus.
  • Live region chatter on every key press; no standard way to read the value.

One slider with a combined value text

The thumb has role="slider" for value, and aria-valuetext says both: "Saturation 76%, brightness 96%".

Left and Right change saturation; Up and Down change brightness.

Pros

  • One tab stop, one announcement with both values.

Cons

  • aria-valuenow can hold only one number, so assistive tech that reads it gets half the truth.
  • Swipe gestures on mobile screen readers change only one axis.

Two visually hidden range inputs

Two native input type="range", one for saturation and one for value, hidden visually but not from assistive tech.

Each has its own label and aria-valuetext; the visible thumb follows them.

Pros

  • Native slider semantics for each axis, including mobile swipe gestures.
  • Form submission and labels for free. React Aria's ColorArea does this.

Cons

  • Two inputs to keep in sync with the pointer logic.
  • Focus styling must move to the visible thumb.

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.

State machine for the color area. Idle loops to itself on an arrow key, firing onChange and onChangeEnd. Idle goes to Dragging on pointerdown, which captures the pointer and jumps the thumb. Dragging loops to itself on pointermove, with at most one onChange per frame. Dragging returns to Idle on pointerup or lostpointercapture, firing onChangeEnd once. Idle goes to Disabled when isDisabled is set, and back when it is cleared.IdlefocusableDraggingpointer capturedDisabledignores inputpointerdown: captureup or lost capture: endmove: 1 change per framearrow key: change + endisDisabled
Keys and drags take different paths but end the same way: exactly one onChangeEnd per completed change. Disabled ignores every input, and the hidden range inputs are disabled too, so screen readers say so.

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?

On every keystroke that parses

Parse on input; if valid, update the color and reformat the field.

Pros

  • Instant feedback.

Cons

  • Partial values like #3b8 are valid and get committed.
  • Reformatting moves the cursor and fights the user.

On blur or Enter only

Keep a local draft string while focused.

Parse on blur and on Enter; commit if valid, show an error if not. Escape restores the last good value.

Pros

  • Never commits a half-typed value.
  • The field never changes under the cursor.

Cons

  • No live preview while typing.

Draft with live preview for complete values

Same draft and commit as above.

While typing, preview the swatch only when the draft is complete and unambiguous (6 or 8 hex digits, or a whole function string), without calling onChange.

Pros

  • Feedback for pasted values at once, no premature commits.

Cons

  • Two kinds of update to explain in the API: preview and commit.

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.

State machine for the color text field. Showing displays the formatted value and loops to itself when a valid paste commits. Typing moves Showing to Editing, where a local draft is kept. From Editing, Enter or blur with a valid value commits and returns to Showing; an invalid value goes to Invalid, which sets aria-invalid and shows a message. From Invalid, typing again returns to Editing. From Editing or Invalid, Escape restores the last value and returns to Showing.Showingformatted valueEditinglocal draftInvalidaria-invalidtypebadvalid paste: committype againEnter, blur: commitEscape (from Editing too): restore
The draft lives only in the field. The shared color changes on just two edges: a valid commit from Editing and a valid paste. Everything else stays local, so consumers never see a half-typed value.

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.

ts
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
Hex is easy to parse by hand. Short forms double each digit.

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.

ts
// 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
  }
}
Sample a color, treating Escape as a normal cancel.

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.

Sequence diagram of the eyedropper, with four lifelines: User, Button, EyeDropper and Picker state. The user clicks the eyedropper button, which calls open with an abort signal while the click still counts as user activation. The user clicks a pixel; the EyeDropper resolves with sRGBHex, and the button commits the color to the picker state, firing onChange and onChangeEnd once. In the failure path, the user presses Escape; the EyeDropper rejects with AbortError, and the button returns focus to itself and changes nothing.UserButtonEyeDropperPicker stateclickopen({ signal })click a pixel{ sRGBHex }commit + endcancel: EscapeEscapeAbortErrorrefocus, no change
Call 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.

GroupWho owns itWhere it livesSize
Presets (brand)The design systemA swatches prop, from tokens10 to 40 colors
Document colorsThe consumer appA swatches group computed from the documentUp to about 20, most used first
Recent colorsThe userlocalStorage under one key, shared by all pickersLast 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).

ts
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),
});
WCAG 2 contrast ratio. Composite alpha over the background first.
Use of the colorAA needsAAA needsWCAG criterion
Normal text4.5:17:11.4.3 and 1.4.6
Large text (24px, or 18.66px bold)3:14.5:11.4.3 and 1.4.6
Icons, borders, focus rings, chart lines3:1no AAA level1.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.

Foreground
Used as
Foreground #3b82f6 at 100% opacity, used as normal text, which needs 4.5 to 1 for AA and 7 to 1 for AAA. On white card #ffffff: 3.67 to 1, AA fail, AAA fail. On gray page #f3f4f6: 3.34 to 1, AA fail, AAA fail. On dark page #111827: 4.82 to 1, AA pass, AAA fail. On brand blue #3b82f6: 1 to 1, AA fail, AAA fail.#3b82f6 at 100%AA needs 4.5:1, AAA 7:1white card3.67:1AA failAAA failgray page3.34:1AA failAAA faildark page4.82:1AA passAAA failbrand blue1:1AA failAAA fail
1 of 4 backgrounds pass AA for normal text. Ratios are rounded down, so 4.49 never shows as a pass.

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.

ApproachExample outputSizeTrade-off
Hue ranges plus lightness and chroma words"dark vivid blue"under 1 KBPredictable, translatable, a bit coarse
Nearest CSS named color (148 of them)"royalblue"about 2 KBNames like rebeccapurple and papayawhip confuse more than help
Large naming list (1,000+ names)"Dodger blue"20 KB or moreRich, 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?

One component with many flags

ColorPicker with props like showAlpha, showEyedropper, showSwatches, layout.

Pros

  • One import, quick to adopt.

Cons

  • Every team pays for every feature in bundle size.
  • Each new layout request becomes another flag; the props list grows without end.

Composable parts over a shared state

ColorPicker is a provider holding the color; ColorArea, ColorSlider, ColorField, SwatchGroup, EyeDropperButton and ContrastBadge read and write it through context.

A pure color core (parse, convert, format, contrast, name) has no React in it.

Pros

  • Teams import only the parts they render.
  • Any layout without new flags; parts also work alone with value and onChange.

Cons

  • More pieces to document; a ready-made default layout is still needed.

Headless hooks only

Ship useColorArea, useColorSlider, and so on; teams write all markup and styles.

Pros

  • Maximum freedom.

Cons

  • Every team rebuilds the same look; accessibility details get lost in each copy.

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.

Design diagram. Inside the Picker zone, the top row holds ColorArea, ColorSlider and ColorField, which write channels to the Picker state. The middle row holds the Recents store, backed by localStorage and written on onChangeEnd, and the Picker state with h, s, v, alpha and the events. The bottom row holds SwatchGroup, ContrastBadge and EyeDropper, which read from and write to the state. Under them, the Color core of pure functions: parse, convert, format, contrast and name, called by every part. In the Outside zone: Design tokens pass swatches to the Consumer app; the Consumer passes value into the Picker state and receives events; the EyeDropper part calls the browser's EyeDropper API.Picker (React)OutsideColorAreapointer, keysColorSliderhue, alphaColorFielddraft, parseRecentslocalStoragePicker stateh, s, v, alphaSwatchGrouppresets, recentContrastratio, AAEyeDropperif supportedColor coreparse, convert, format, contrast, namewriteread, writepure callsTokensbrand paletteConsumervalue, eventsBrowserEyeDropperswatchesvalueeventsopen()
Every part talks to one state, and every part uses the same pure core. Only three arrows leave the picker: value and events with the consumer, and open() to the browser's EyeDropper.
ModuleResponsibilityWhy the boundary is here
Color coreParse, 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 slidersPointer, keyboard and hidden inputs; write channels.Same interaction code for every channel.
FieldDraft text, parsing, format switch.Its draft must not leak into shared state.
Swatches and recents storePreset groups from props; recents in localStorage.Recents are per user, not per picker.
Eyedropper buttonFeature detection, activation, abort.Rendered only where supported.
Contrast badgeRatio 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.

Data flow for editing a label color. Down the left: the Labels API sends JSON with the color hex to the Consumer store; the store passes the value prop to the Picker state; the state renders the parts. From the right, the user drags, types or samples in the parts, which write s and v to the Picker state once per frame. The Picker state sends onChange to a live preview, a CSS variable, and sends onChangeEnd to the Recents in localStorage and to the Consumer store, which sends PATCH with the color to the Labels API.Labels APIstores color hexConsumer storelabel.colorPicker stateh, s, v, alphaPartsarea, sliders, fieldJSONvaluerenders, v per framePATCH on endLive previewCSS variableRecentslocalStorageuseronChangeonChangeEndenddrag, type, sample
Reads flow down as a hex string. Changes flow up at two speeds: 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.

DataLives inLifetime
Editing color (h, s, v, alpha or l, c, h, alpha)Picker provider stateWhile the picker is mounted
Field draft text and errorField component stateWhile the field is focused
Display format (hex, rgb, hsl, oklch)Provider, seeded from the format propPer picker; optionally saved by the consumer
Recent colorslocalStorage, one keyAcross sessions, per browser
Presets, document colors, contrast backgroundPropsOwned by the consumer
The saved colorThe consumer's store or serverForever; the picker never saves it
ts
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
The value consumers receive: immutable, with every format derived on demand.
tsx
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>
Props of the provider. Every part also accepts value, defaultValue and onChange to work alone.

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 with OffscreenCanvas if it ever costs more than a few milliseconds on low-end phones.
  • Read layout once per drag. Call getBoundingClientRect on pointerdown and 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 requestAnimationFrame callback. However many events arrive in one frame, they become one state update and one render.
  • Keep onChange work 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?

On every pointer event

Call onChange inside the pointermove handler.

Pros

  • Simplest code.

Cons

  • Can exceed the screen's frame rate; consumers render work nobody sees.
  • Expensive consumers drop frames and the thumb lags.

Once per animation frame, plus an end event

Coalesce events in requestAnimationFrame; call onChange once per frame.

Call onChangeEnd on release and on each discrete change.

Pros

  • Never faster than the screen can show.
  • Clear split: cheap preview vs expensive commit.

Cons

  • Consumers must learn two events.

Throttled to a fixed rate

Call onChange at most every 100 ms, plus a trailing call.

Pros

  • Caps work for very heavy consumers.

Cons

  • Preview looks choppy at 10 updates a second.
  • A fixed number is wrong for both fast and slow consumers.

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.

Two timelines of one 16.7 millisecond frame with example timings. Top, naive: work for each of 10 pointer events, 12 milliseconds of renders and 4 of onChange calls, then 3 of style and paint, ending at 19 milliseconds, past the frame line: a dropped frame. Bottom, batched: one render of about 1.2 milliseconds, one onChange, 3 milliseconds of style and paint, and about 12 milliseconds idle, inside the frame.16.7 msnaive: work for each of 10 events10 renders10 callspaint19 msbatched: one update per frame1paintidle: 12 ms1 render, 1 onChange, then paint
Example timings, not measurements, for a browser that delivers every raw event (Chrome already aligns pointermove to frames). The point is the shape: work multiplied by the event rate overflows the frame, while one update per animation frame leaves most of it idle.

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.

MinutesWhat to doWhat to say out loud
0 to 5Ask 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 10Draw the wireframe: area, hue and alpha sliders, field, swatches."HSV square, because every corner is useful, unlike HSL."
10 to 18State: store the edited model in floats, derive hex."Hex loses hue at gray and drifts; the thumb position is the state."
18 to 28Area interaction: pointer capture, clamping, keyboard steps, two hidden sliders."There is no 2D slider role, so I expose two native ones."
28 to 36API: parts over a provider, value and Color, onChange vs onChangeEnd."Cheap preview every frame, expensive commit once."
36 to 45Pick 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?"

Think first

Follow-up: "Two pickers on the page edit the same theme color. How do they stay in sync?"

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 and touch-action: none make 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 AbortError as 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 onChange at most once per frame and onChangeEnd once 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next