# Colour pickers you can only drag, with no field to type

Source: https://easeweb.dev/learn/colour-pickers
Topics: Custom inputs > Colour pickers let people type a value (WCAG 2.1.1, 4.1.2, 2.5.7)
The fix: Either add a labelled hex field beside the picker, or use the browser's own colour input, or rebuild the square and the hue strip as range inputs that take the arrow keys, read their values and set the colour where a click lands, with the hex field beside them. The first two are partial: the hex field leaves people who can't drag choosing by code, and the browser's input looks and works differently in each browser.
Test with: keyboard, screen reader, mouse, touch
References: https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F108 https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/color https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/range

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A booking app lets each business set a brand colour, which it uses for the Book now button on their booking page. The settings page draws the picker that design tools made familiar: a square that runs from white to the full colour across and from light to black down, a rainbow strip under it for the hue, and the colour's code, #366cd9, written under the strip. Beside the picker, a preview shows the booking page's button in the chosen colour. With a mouse, you grab the round handle in the square and drag it, then drag the handle on the strip. A click anywhere else on the square does nothing; only the handles move.

Press Tab and focus goes straight from the top of the settings to Save: nothing in the picker takes focus. A screen reader reads "#366cd9" as text, if it reaches it, and nothing that can change it. And someone with the brand guide open, the colour written there as a hex code, has nowhere to type it.

## Why it fails

The handles are `span` elements that a script moves on pointer events, and the script listens for a press on the handles alone. To the browser, the square and the strip are pictures drawn with CSS gradients and the handles are empty spans, so it gives them no Tab stop, no keys and nothing to announce. The picker fails three criteria at once. Nothing in it can be done from the keyboard, which fails 2.1.1. The handles have no role, name or value, so a screen reader can't say there is a control here or what colour it is set to, which fails 4.1.2; the code under the strip is text, not a field. And the only way to change the colour is to drag, which fails 2.5.7. Choosing a colour doesn't need a drag: WCAG's Understanding page for 2.5.7 names a text field for the colour's value, or a click on another spot in a colour wheel, as ways to set it without dragging.

Keyboard support alone doesn't settle 2.5.7, and a field alone doesn't settle everything either. Someone on a touch screen has no arrow keys, so a square that only the keys and a drag move still fails for them. And a field is the only way in for a person who can't drag but chooses colours by eye, which is why the recommended fix below keeps both.

## Who is affected

The brand colour is set once and then shows on every page of the business's booking site, so a person who can't set it has to ask someone else to do it for them. On a phone, the handles are small and the finger covers the colour it is choosing, which makes an exact shade hard for anyone.

## What automation and AI miss

A scanner sees two gradients, a few empty spans and a line of text, and finds nothing wrong: none of them claims to be a control, so no rule asks them for a name or a Tab stop. The audit's keyboard check finds controls by their role or a click handler written in the markup, and these handles have neither. EaseWeb's audit sends a review lead for a colour square or a hue strip drawn with CSS gradients that has a handle but nothing in it that takes focus, no slider and no field, because only a person can try whether the colour can be set some other way on the page. Asked to fix the picker, a code assistant may add `role="slider"` and `tabindex="0"` to the square's handle and stop there: a slider has one value, the square has two, and no field appears for the code.

## Before: a square and a hue strip that only a drag moves

```html
<div class="brand-colour">
  <span class="label">Brand colour</span>
  <div class="sv-area" style="background-color: hsl(220 100% 50%)">
    <span class="handle" style="left: 75%; top: 15%"></span>
  </div>
  <div class="hue-strip">
    <span class="handle" style="left: 61%"></span>
  </div>
  <div class="preview" aria-hidden="true" style="--brand: #366cd9">…</div>
  <span class="hex">#366cd9</span>
</div>
```

```js
for (const handle of picker.querySelectorAll('.handle')) {
  handle.addEventListener('pointerdown', startDrag);
}
// pointermove moves the handle and sets the preview and the hex text; pointerup ends the drag
```

The handles can't take focus, so the keyboard never reaches them. They have no role, name or value, so assistive technology doesn't know the picker is there. A press listener on the handles is the only way in, so every colour needs a drag, and the code is written out where no one can type.

## After: a hex field beside the picker

```html
<div class="brand-colour" role="group" aria-labelledby="brand-label">
  <span class="label" id="brand-label">Brand colour</span>
  <div class="sv-area" style="background-color: hsl(220 100% 50%)" aria-hidden="true">
    <span class="handle" style="left: 75%; top: 15%"></span>
  </div>
  <div class="hue-strip" aria-hidden="true">
    <span class="handle" style="left: 61%"></span>
  </div>
  <div class="preview" aria-hidden="true" style="--brand: #366cd9">…</div>
  <label for="brand-hex">Hex</label>
  <input type="text" id="brand-hex" value="#366cd9" maxlength="7" spellcheck="false" autocomplete="off">
</div>
```

```js
for (const handle of picker.querySelectorAll('.handle')) {
  handle.addEventListener('pointerdown', startDrag);
}
const hex = document.getElementById('brand-hex');
// A typed code moves the handles and fills the preview, through the picker's own setColour()…
hex.addEventListener('input', () => {
  const code = hex.value.trim();
  if (/^#?[0-9a-f]{6}$/i.test(code)) setColour(code.startsWith('#') ? code : `#${code}`);
});
// …and the drag writes into the field: hex.value = colour;
```

The picker stays as it was for people who like to drag, and a labelled text field takes the colour as a code, by typing, pasting or voice. It is native, so it is in the Tab order with a role, a name ("Hex", in the group "Brand colour") and a value, and the drag writes into it, so it always shows the colour the handles have set. `aria-hidden` takes the drag-only square and strip out of the accessibility tree, so a screen reader meets the field and not two handles with no role or name. It is the smallest change to an existing picker, and it gives everyone an exact value. But it is not enough on its own: a keyboard or voice user who wants the same blue a little lighter has to work out a new code, while a mouse user just moves the handle. It suits a setting where people arrive with a code, such as a brand guide, more than one where they choose by eye.

## After: the browser's own colour input

```html
<div class="brand-colour">
  <label for="brand-colour">Brand colour</label>
  <input type="color" id="brand-colour" value="#366cd9">
  <output for="brand-colour">#366cd9</output>
  <div class="preview" aria-hidden="true" style="--brand: #366cd9">…</div>
</div>
```

```js
const input = document.getElementById('brand-colour');
const [output, preview] = ['output', '.preview'].map((part) => input.parentElement.querySelector(part));
input.addEventListener('input', () => {
  output.textContent = input.value;
  preview.style.setProperty('--brand', input.value);
});
```

The page gets a native control: it is in the Tab order, its label names it, Enter or Space opens the picker, and its value is a six-digit hex code in lower case (the newer `alpha` attribute and `colorspace="display-p3"` turn it into a `color()` value, in the browsers that support them). The picker belongs to the browser, so 2.5.7 leaves it to the browser too, but each browser draws a different one, and the page can't change any of them:

- Chrome and Edge on a computer open their own pop-up: a colour area and a hue slider that take the arrow keys, fields for the value (red, green and blue at first, with a button that switches them to HSL and hex), and an eyedropper.
- Safari on a Mac opens a small panel of colours, with a way into the Mac's Colors window, whose sliders have a hex field. On iPhone and iPad, Safari opens the system's colour picker, whose Sliders tab has one too.
- Firefox on a computer opens the operating system's own dialog. On Windows, that dialog takes red, green and blue, or hue, saturation and luminosity, as numbers, but no hex code.
- Chrome on Android shows a set of colours and a Custom view of three sliders, with no field to type into.

So a value can be typed in most of them, but not all, and the closed control is a small swatch the page can style only so far. It is the least code, and a fair choice for a setting few people use, but test it in every browser your users have.

## After: the square and the strip as sliders, with the hex field

```html
<div class="brand-colour" role="group" aria-labelledby="brand-label">
  <span class="label" id="brand-label">Brand colour</span>
  <div class="sv-area" style="background-color: hsl(220 100% 50%)">
    <span class="handle" style="left: 75%; top: 15%"></span>
    <input type="range" class="visually-hidden" id="saturation" aria-label="Saturation" min="0" max="100" value="75" aria-valuetext="75%">
    <input type="range" class="visually-hidden" id="brightness" aria-label="Brightness" min="0" max="100" value="85" aria-valuetext="85%">
  </div>
  <input type="range" class="hue-strip" id="hue" aria-label="Hue" min="0" max="359" value="220" aria-valuetext="220°, blue">
  <div class="preview" aria-hidden="true" style="--brand: #366cd9">…</div>
  <label for="brand-hex">Hex</label>
  <input type="text" id="brand-hex" value="#366cd9" maxlength="7" spellcheck="false" autocomplete="off">
</div>
```

```js
const [sat, bright, hue, hex] = ['saturation', 'brightness', 'hue', 'brand-hex'].map((id) => document.getElementById(id));
const [area, handle, preview] = ['.sv-area', '.sv-area .handle', '.preview'].map((part) => picker.querySelector(part));
const HUES = [[15, 'red'], [45, 'orange'], [70, 'yellow'], [165, 'green'], [195, 'cyan'], [255, 'blue'], [290, 'purple'], [345, 'pink'], [360, 'red']];
// Moves the handle, the square's colour and the values read out to match the sliders.
function place() {
  sat.setAttribute('aria-valuetext', `${sat.value}%`);
  bright.setAttribute('aria-valuetext', `${bright.value}%`);
  hue.setAttribute('aria-valuetext', `${hue.value}°, ${HUES.find(([end]) => hue.valueAsNumber < end)[1]}`);
  area.style.backgroundColor = `hsl(${hue.value} 100% 50%)`;
  handle.style.left = `${sat.value}%`;
  handle.style.top = `${100 - bright.value}%`;
}
function show(colour) {
  preview.style.setProperty('--brand', colour);
}
// The sliders set the colour; toHex() and toHsv() are the picker's own conversions.
for (const range of [sat, bright, hue]) {
  range.addEventListener('input', () => {
    place();
    hex.value = toHex(hue.valueAsNumber, sat.valueAsNumber, bright.valueAsNumber);
    show(hex.value);
  });
}
// A press anywhere on the square sets both values there: no drag needed.
area.addEventListener('pointerdown', (event) => {
  const box = area.getBoundingClientRect();
  sat.value = Math.round(((event.clientX - box.left) / box.width) * 100);
  bright.value = Math.round(100 - ((event.clientY - box.top) / box.height) * 100);
  sat.dispatchEvent(new Event('input'));
  sat.focus();
  startDrag(event); // moving on while pressed drags, as before
});
// A typed code moves the sliders, and the colour stays exactly as typed.
hex.addEventListener('input', () => {
  const code = hex.value.trim();
  if (!/^#?[0-9a-f]{6}$/i.test(code)) return;
  [hue.value, sat.value, bright.value] = toHsv(code);
  place();
  show(code.startsWith('#') ? code : `#${code}`);
});
```

The square keeps its look, and holds two range inputs that can't be seen: Saturation, which the handle shows across, and Brightness, which it shows up and down. The hue strip is a range input itself, drawn with the rainbow as its track. All three are native, so the browser gives them focus, the arrow keys, Page Up, Page Down, Home and End, and a role and value a screen reader reads. The hue is read with a colour name, "220°, blue", because a number of degrees means little to most people. A press anywhere on the square sets both values there, and a click on the strip moves its thumb there, so no colour needs a drag, and the drag still works for people who like it. The hex field from the first fix stays, so a code from a brand guide can be pasted in and is kept exactly: converting it to the sliders' whole numbers and back would round it. Draw a focus ring on the square's handle while either hidden input has focus; the inputs can't show one themselves.

## Which option to use

All three pass. The hex field is the smallest change and gives everyone an exact value, but people who can't drag still choose colours only by code. The browser's own colour input is the least code, but every browser draws a different picker, not all of them take a typed value, and the page can't put it inline or style its pop-up. The sliders with the hex field are the only one that lets keyboard and screen reader users choose by eye or by code, the same in every browser, while keeping the picker as it was drawn; use them whenever the picker is your own.

## Implementation decisions

What a screen reader reads should mean something to the person hearing it. A hex code means little to most people, and "75%" for saturation not much more, so name the hue in its `aria-valuetext`, and consider writing the whole colour's name beside its code ("dark blue"), where everyone can read it. The preview is a picture of the booking page, not a control, so it is hidden from assistive technology and drawn apart from the form's own buttons. Chrome gives screen readers its own colour input's value as percentages of red, green and blue ("21% red 42% green 85% blue").

A few things decide how the picker behaves once it is in a page:

- One Tab stop or two: the two inputs in the square are two Tab stops, which is simple and uses the browser's own keys. A library may keep the square to one Tab stop, with the left and right arrows moving one hidden input and the up and down arrows the other; React Aria's colour area works this way.
- Focus and contrast: the hidden inputs draw no focus ring, so draw one on the handle, for example with `.sv-area:has(:focus-visible) .handle`. A white ring with a dark edge keeps the handle visible on every colour of the square; see [low-contrast form fields, icons and focus rings](/learn/non-text-contrast).
- The hex field: accept the code with or without the `#`, and leave a code that isn't valid as it was typed, with a message beside the field rather than a silent reset; see [form errors that don't say how to fix them](/learn/accessible-form-errors).
- Touch: `touch-action: none` on the square and the strip stops a drag on them from scrolling the page, but a finger can't scroll the page from there either, so keep them small enough that there is always room around them.
- A picker that opens from the field: the square and the strip may pop up under the hex field when it takes focus, as long as focus stays in the field, typing still works, Escape closes them and they don't cover the next field. Content that appears without moving focus is a change of content, not of context, but it must stay open until focus leaves or the person closes it, and close without moving focus; see [dropdowns and fields that change the page on their own](/learn/context-changes) and [tooltips that vanish on approach and can't be dismissed](/learn/hover-content).
- Forced colors: a contrast theme replaces the background colour that paints the square's hue and the preview, and removes the gradients that shade the square and draw the strip, so set `forced-color-adjust: none` on the square, the strip and the preview, and give the handles a border in a system colour.

Colour picker libraries vary. Some put `role="slider"` on the square with no `aria-valuenow`, which axe reports; some take keys but no press on the square; some have no field at all. Check what each part that takes focus exposes, not what the library's documentation promises.

## Verify the fix

1. Put the mouse aside. Press Tab and confirm you reach every part that sets the colour: the hex field, each slider, or the browser's colour input.
2. Type a hex code such as #2a9d8f, in the field or in the browser picker's own field where it has one, and confirm the preview shows exactly that colour, and that the page saves it.
3. With a screen reader, confirm each part announces its name, its role and its value, that the hue is read with a colour name or the colour as its code, and that the value is read again after each change.
4. With a mouse, using clicks only, set a new colour: a click on the square, a click on the strip, a typed code, or the browser's own picker. No step may need the pointer to move while pressed. Repeat with taps on a touch screen, and with voice control.
5. If you use the browser's colour input, open it in each browser your users have, on a computer and a phone, and confirm a colour can be set there from the keyboard and, where your users need it, typed as a code.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the picker or its library changes.

## Limits

This is a starting pattern, not a guarantee for every application. Screen readers differ in how they read range inputs: some read the value before the name, and on iOS they move by swiping up and down rather than with keys. The browser's own colour pickers change between versions, so check the ones your users have. A single slider for a price or a volume is covered in [range sliders that only move by dragging](/learn/sliders), and gestures in general in [pinch, drag and tilt gestures with no button](/learn/gestures). Whether white text stays readable on the colour someone picks is a separate check: see [low-contrast text on brand colours, photos and hover states](/learn/text-contrast). Test the complete setting with your target assistive technology.

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
