Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
On white4.47:1AA large only

Used for buttons, links and focus rings.

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.

Emerald · #10b981

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.

Passes AA
Deploy to production
7.90:1AA text
Fails
Deploy to production
2.15:1Fails

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.

#6366F1→#6366f1
6366f1→#6366f1
#63f→#6366f1

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.

A link in the same colour

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.

Swatch
Selected
Focus
Light swatch
Hex field
Invalid hex
AA text
AA badge
Fails
Fail badge
Eyedropper

Anatomy

Every part, every measurement, and the reason it is that number.

On white4.47:1AA large only

Swatches at the top because they answer most choices, a spectrum for the rest, and a hex field that makes any of it reproducible.

  1. Swatch24 × 24px, 6px gap

    Large enough to judge the colour, small enough that twelve fit on one row. Below 20px, similar hues become indistinguishable.

  2. 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.

  3. 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.

  4. Spectrum80px tall

    Deliberately secondary. It is the escape hatch for a colour not in the set, not the primary interface.

  5. Hex fieldMonospace, uppercase

    The exact, shareable value. Monospace so a column of hex codes aligns and so 0 and O cannot be confused.

  6. 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.

  7. Eyedropper32px, when supported

    Feature-detected. The EyeDropper API is not everywhere, and a dead button is worse than a missing one.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
--space-1-5Gap between swatches

Radius

TokenValueUsed for
--radius-smSwatch corners
--radius-mdSpectrum and preview corners

Typography

TokenValueUsed for
font-mono—The hex value

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightLabel gapMin widthTouch targetWhen to use
Swatch24px6px24px44px on coarse pointersThe 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.
Spectrum80px———Secondary by design. Taller makes it look like the main event.
Preview chip32px—32px—Beside the hex field, showing the parsed value rather than the typed text.

Do

Lead with swatchesAlmost every real choice is one of a set. A named swatch is one click, reproducible, and something a colleague can name back to you.
Always include a hex fieldIt is the only exact, paste-able, shareable representation, and the only precise path for a keyboard user.
Deploy to production
7.90:1AA text
Show contrast against the real surfaceIt is the cheapest possible accessibility intervention: catching the failure while the colour is being chosen rather than in an audit months later.
Give pale swatches an edgeA white swatch on a white panel is invisible, and the user cannot tell whether the option exists or the picker is broken.

Don't

Do not make the spectrum the only pathDragging produces #6f66ee rather than #6366f1. Nobody can see the difference and nobody can reproduce it on purpose.
Error colour
Do not let users pick status coloursRed means failure because the system says so. Making that a preference breaks the meaning across every surface at once.
Do not identify colours by colour aloneA grid of unlabelled swatches is unusable for anyone with a colour vision deficiency, and unnameable for everyone else.
Do not offer alpha unless it is usedA transparency slider turns every chosen colour into a value that behaves differently on every background — and users reach for it to make things "softer" when they wanted a lighter shade.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.1.1Non-text ContentA1.4.1Use of ColorA2.1.1KeyboardA2.5.7Dragging MovementsAA4.1.2Name, Role, ValueA

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

TabReaches 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 / EnterSelects the focused swatch.
← / →On the spectrum, adjusts by one step — this is the non-dragging alternative 2.5.7 requires.
TypeIn 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.
AttributeApplied toNotes
role="radiogroup"The swatch gridOne value, mutually exclusive. Each swatch is role="radio" with aria-checked.
aria-labelEach swatchThe name and the value: "Indigo #6366f1". A swatch with no name is unusable without colour vision.
role="slider"Spectrum axesWith aria-valuenow and aria-valuetext, so hue and saturation are adjustable by keyboard.
aria-labelThe hex field"Hex value". It is the precise path and must be findable.
role="status"The contrast readoutAnnounces the ratio and the verdict as the colour changes, debounced.
aria-invalidThe hex fieldWhile the string does not parse, with the last valid colour still applied.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
showSpectrumbooleantrueTurn 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.
alphabooleanfalseOnly enable where transparency is genuinely used. It makes every value background-dependent.

Notes

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.