Color Picker
Swatches first, spectrum second, hex field always. Most colour choices are a pick from a set, not an exploration of a gamut.
Also called Swatch Picker, Eyedropper, Hex Input — in this system all of them are Color Picker.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Most pickers should be swatches only
Twelve labelled colours cover a label picker, a category colour, a calendar tag. Adding a spectrum invites values nobody can reproduce.
Contrast as you choose
The ratio against the surface the colour will actually sit on, checked live. It is the cheapest accessibility intervention available anywhere in a design system.
The hex field carries everything
Paste-able, typeable, shareable, and the only precise path for a keyboard user. It should accept 3-digit shorthand, a missing hash, and uppercase.
Preview it where it lands
A swatch in a panel tells you the colour. The same colour behind a real button tells you whether it works, which is the actual question.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Swatches at the top because they answer most choices, a spectrum for the rest, and a hex field that makes any of it reproducible.
- Swatch24 × 24px, 6px gap
Large enough to judge the colour, small enough that twelve fit on one row. Below 20px, similar hues become indistinguishable.
- SelectionRing + offset, plus a check
A ring rather than a border, so the swatch never changes size and the row does not jump as the selection moves. The check is the second, non-colour signal.
- Light-swatch border1px on pale colours
A white swatch on a white panel is invisible. Every swatch needs an edge that survives its own lightness.
- Spectrum80px tall
Deliberately secondary. It is the escape hatch for a colour not in the set, not the primary interface.
- Hex fieldMonospace, uppercase
The exact, shareable value. Monospace so a column of hex codes aligns and so 0 and O cannot be confused.
- Contrast readoutRatio + AA verdict
Against the surface the colour will actually sit on. A number with no pass/fail verdict makes the reader do the work.
- Eyedropper32px, when supported
Feature-detected. The EyeDropper API is not everywhere, and a dead button is worse than a missing one.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-overlay | — | Picker panel |
| --ds-border-subtle | — | Swatch and preview edges |
| --ds-border-strong | — | Edge on a pale swatch |
| --ds-fg | — | Selection ring |
| --ds-focus-ring | — | Focus outline |
| --ds-success-text | — | A passing contrast verdict |
| --ds-danger-text | — | A failing verdict and an invalid hex |
Spacing
| Token | Value | Used for |
|---|---|---|
| --space-1-5 | Gap between swatches |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-sm | Swatch corners | |
| --radius-md | Spectrum and preview corners |
Typography
| Token | Value | Used for |
|---|---|---|
| font-mono | — | The hex value |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Label gap | Min width | Touch target | When to use |
|---|---|---|---|---|---|
| Swatch | 24px | 6px | 24px | 44px on coarse pointers | The primary control. Twelve fit comfortably on one row at this size. |
| Compact panel | — | — | 208px | — | Swatches and a hex field only. No spectrum. |
| Default panel | — | — | 240px | — | Swatches, spectrum, hex field and contrast readout. |
| Spectrum | 80px | — | — | — | Secondary by design. Taller makes it look like the main event. |
| Preview chip | 32px | — | 32px | — | Beside the hex field, showing the parsed value rather than the typed text. |
Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Every swatch needs an edge that survives its own lightness — a 3:1 border, or an inset ring on pale colours.
- The selection ring must reach 3:1 against both the swatch and the panel, which is why it is drawn with an offset.
- The selected state carries a check as well as a ring, so it does not depend on the ring being visible against that particular colour.
- The contrast readout must state a verdict, not only a number. "3.1:1" leaves the reader to remember the thresholds.
Keyboard
| Tab | Reaches the swatch grid, the spectrum, the hex field and the eyedropper as separate stops. |
| ← / → / ↑ / ↓ | Moves between swatches, wrapping across rows. The grid is one tab stop with roving focus. |
| Space / Enter | Selects the focused swatch. |
| ← / → | On the spectrum, adjusts by one step — this is the non-dragging alternative 2.5.7 requires. |
| Type | In the hex field, sets the value live as soon as the string parses. |
Screen readers
- Swatches announce as "Indigo #6366f1, radio button, selected, 1 of 8".
- Announce the contrast verdict when it changes: "4.8 to 1, passes AA for text".
- The spectrum should announce meaningfully — "hue 243 degrees" — rather than as a bare number between 0 and 360.
Focus & touch
- The swatch grid uses roving tabindex so Tab crosses it once. Selecting a swatch keeps focus on it, so the user can arrow to a neighbour and compare. The hex field keeps its caret when the value is set from elsewhere in the picker.
- Swatches need 44px targets, which usually means six per row rather than twelve. The spectrum needs a non-dragging alternative under WCAG 2.5.7 — arrow-key stepping or numeric fields — and the hex field with a numeric-friendly keyboard is the most reliable path on a phone. The EyeDropper API does not exist on mobile at all, so feature-detect rather than assuming.
| Attribute | Applied to | Notes |
|---|---|---|
| role="radiogroup" | The swatch grid | One value, mutually exclusive. Each swatch is role="radio" with aria-checked. |
| aria-label | Each swatch | The name and the value: "Indigo #6366f1". A swatch with no name is unusable without colour vision. |
| role="slider" | Spectrum axes | With aria-valuenow and aria-valuetext, so hue and saturation are adjustable by keyboard. |
| aria-label | The hex field | "Hex value". It is the precise path and must be findable. |
| role="status" | The contrast readout | Announces the ratio and the verdict as the colour changes, debounced. |
| aria-invalid | The hex field | While the string does not parse, with the last valid colour still applied. |
Example usage
1import { ColorPicker } from '@/ui/Input'23<Field label="Brand colour" description="Used for buttons, links and focus rings.">4 <ColorPicker5 value={brand}6 onChange={setBrand}7 swatches={BRAND_SWATCHES} // most choices land here8 showContrast={{ against: '#ffffff', level: 'AA' }}9 />10</Field>1112// Parse loosely: shorthand, missing hash, uppercase are all the same colour.13function parseHex(input: string): string | null {14 const s = input.trim().replace(/^#/, '').toLowerCase()15 if (/^[0-9a-f]{3}$/.test(s)) return '#' + s.split('').map((c) => c + c).join('')16 if (/^[0-9a-f]{6}$/.test(s)) return '#' + s17 return null18}1920// Real contrast, not eyeballed. This is the whole reason to show a readout.21function contrastRatio(a: string, b: string) {22 const lum = (hex: string) => {23 const n = hex.slice(1)24 const [r, g, bl] = [0, 2, 4].map((i) => parseInt(n.slice(i, i + 2), 16) / 255)25 const f = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)26 return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(bl)27 }28 const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x)29 return (hi + 0.05) / (lo + 0.05)30}3132// Feature-detect: a dead eyedropper button is worse than a missing one.33const hasEyeDropper = typeof window !== 'undefined' && 'EyeDropper' in window34async function pick() {35 const { sRGBHex } = await new (window as any).EyeDropper().open()36 onChange(sRGBHex)37}Framework-free HTML
<div class="ds-colorpicker">
<!-- One value, mutually exclusive: a radiogroup, not eight buttons. -->
<div role="radiogroup" aria-label="Preset colours" class="ds-swatches">
<button type="button" role="radio" aria-checked="true"
aria-label="Indigo #6366f1" style="--swatch: #6366f1" tabindex="0">
<svg aria-hidden="true">…</svg>
</button>
<button type="button" role="radio" aria-checked="false"
aria-label="Violet #8b5cf6" style="--swatch: #8b5cf6" tabindex="-1"></button>
</div>
<!-- Keyboard-adjustable: WCAG 2.5.7 needs a non-dragging path. -->
<div class="ds-spectrum">
<div role="slider" aria-label="Hue" aria-valuemin="0" aria-valuemax="360"
aria-valuenow="243" aria-valuetext="243 degrees" tabindex="0"></div>
</div>
<label for="hex" class="sr-only">Hex value</label>
<input id="hex" type="text" value="#6366F1" spellcheck="false"
autocapitalize="off" class="ds-hex" />
<p role="status" aria-live="polite">4.8 to 1 on white, passes AA for text</p>
</div>CSS
.ds-swatches {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 6px;
}
.ds-swatches button {
inline-size: 24px;
block-size: 24px;
border-radius: var(--radius-sm);
background: var(--swatch);
/* Every swatch needs an edge that survives its own lightness: a white
swatch on a white panel is otherwise invisible. */
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.12);
}
/* A ring, not a border: a border changes the box size and the whole row
jumps as the selection moves. */
.ds-swatches [aria-checked='true'] {
outline: 2px solid var(--ds-fg);
outline-offset: 2px;
}
.ds-spectrum {
block-size: 80px; /* secondary by design */
border-radius: var(--radius-md);
background:
linear-gradient(to right, #fff, var(--hue)),
linear-gradient(to bottom, transparent, #000);
background-blend-mode: multiply;
}
.ds-hex {
/* 0 and O must be distinguishable in a value people transcribe. */
font-family: var(--font-mono);
text-transform: uppercase;
}
@media (pointer: coarse) {
/* 44px targets mean six per row, not twelve. */
.ds-swatches { grid-template-columns: repeat(6, 1fr); gap: 10px; }
.ds-swatches button { inline-size: 44px; block-size: 44px; }
}Component API
ColorPicker
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | A six-digit hex string. One canonical format in state, however it was entered. |
| onChange* | (hex: string) => void | — | Fires only on a valid parse, so state never holds a half-typed value. |
| swatches | { hex: string; name: string }[] | — | The primary interface. Every swatch needs a name — colour alone is not an identifier. |
| showSpectrum | boolean | true | Turn it off wherever the allowed set is fixed, which is most of the time. |
| showContrast | { against: string; level: 'AA' | 'AAA' } | — | Live ratio and verdict against the surface the colour will actually sit on. |
| alpha | boolean | false | Only enable where transparency is genuinely used. It makes every value background-dependent. |
Professional tips
- Show recently used colours below the swatches. In any tool where people colour many things, the last five cover most of the next choices.
- Suggest an accessible neighbour when the chosen colour fails contrast — "try #4338ca" is far more useful than a red badge.
- Preview the colour where it will actually be used, not only as a swatch. A brand accent looks different as a 4px focus ring than as a 24px square.
- Store one canonical format. Accepting hex, rgb and hsl on input is friendly; storing all three is a bug waiting to happen.
- For theme builders, derive the full ramp from the chosen colour rather than asking for ten values. One decision, ten outputs.
Performance
- Throttle live preview updates to animation frames. Dragging the spectrum fires far more often than the screen refreshes.
- Compute contrast on a debounce, not per pointer event. The maths is cheap but the re-render it triggers is not.
- Render the spectrum with CSS gradients rather than a canvas. It scales, it costs nothing, and it survives a device-pixel-ratio change.
- Do not put the picker panel in the DOM until it opens. Gradients and swatch grids are pure overhead when closed.
Common mistakes
- A spectrum with no hex field, so no value can be matched to a brand guideline.
- Unlabelled swatches, identifying colours by colour alone.
- No edge on pale swatches, making white invisible on a white panel.
- A border rather than a ring for selection, so the grid jumps as the selection moves.
- Letting users override semantic status colours.
- An alpha slider nobody needs, producing values that behave differently on every background.
- A drag-only spectrum, failing WCAG 2.5.7.
- An eyedropper button on platforms with no EyeDropper API.
Real-world recommendations
- In label and tag pickers, twelve swatches with no spectrum is almost always the right control. The freedom of a full picker produces sixty near-identical greens across a workspace.
- Theme builders should constrain hard. Users pick colours that fail contrast constantly, and a live verdict at the point of choice prevents more failures than any audit.
- The EyeDropper API is genuinely useful for matching an existing brand, and genuinely unavailable on mobile and in Safari. Feature-detect and treat it as a bonus.
- If your product has a design system, the honest answer is usually that users should not be picking arbitrary colours at all — offer the palette and keep the meaning intact.