Color Picker
A named palette of colours, chosen one at a time, plus the swatch it is built from.
Patients tagged Awaiting histology will show as teal.
Installation
npx shadcn@latest add https://qure-ui.qure.ai/r/color-picker.jsonUsage
import { ColorPicker, ColorSwatch, type ColorOption } from '@/components/ui/color-picker'const TAG_COLOURS: ColorOption[] = [
{ value: '#0b7285', name: 'Teal' },
{ value: '#c92a2a', name: 'Red' },
]
<ColorPicker label="Tag colour" colors={TAG_COLOURS} value={colour} onValueChange={setColour} />name is not optional and that is deliberate. It is what a screen reader announces, and it is
also what the picker has instead of a tooltip — see Naming below.
Composition
<ColorPicker> {/* role="radiogroup", owns the selection and the tab stop */}
<button role="radio"> {/* generated per colour — named, and the ring lives here */}
<ColorSwatch /> {/* the fill, the checkerboard, the hairline, the check mark */}
</button>
</ColorPicker>ColorSwatch is exported on its own because it is useful outside a picker: the colour beside a
tag in a worklist row, or the trigger that opens the palette. It is presentational — a swatch is
not a control, so give it a name only when nothing beside it already says what the colour means.
The swatch
Three sizes — 16, 24 and 32px — and two shapes. Two things a bare background-color gets wrong,
and both are handled here:
- A pale colour on a pale page has no edge. Every swatch carries an inset hairline in
border-base-tertiary, the lightest border token that clears WCAG 1.4.11's 3:1, and the same value in both themes — which matters, because the colour it outlines does not change with the theme either. - A transparent fill looks like a bug. A checkerboard sits under every swatch. Under an
opaque colour it costs nothing and is never seen; under
transparentit is the whole picture.
The check mark chooses its own ink from the fill's relative luminance, so it stays legible on a
near-black colour and on a near-white one. Where the colour cannot be parsed — a named colour, an
oklch(), a var() — or is mostly transparent, it falls back to icon-base-default, because
what shows through in that case is the themed checkerboard rather than the fill.
A custom colour
allowCustom adds a hex field under the grid. It earns its place: qtrack's tag editor has a
"choose a custom colour" path behind its presets, and a palette with no escape hatch means the
next colour anyone needs becomes a code change.
It commits on Enter or on blur, never per keystroke — typing #ffffff passes through #fff, and
a picker that flashes white halfway through a word is worse than one that waits. Three-digit
shorthand is expanded, a missing # is added, case is normalised, and anything else marks the
field aria-invalid with a message in an alert rather than silently keeping the old colour.
A free hex field is where a palette's accessibility guarantees stop. The preset colours were
chosen against the surfaces they land on; #fafafa typed into the box was not. If the colour
ends up behind text, check the contrast at the point of use.
In a popover
The shape qtrack actually uses: a swatch that opens the palette, so the colour does not take a
row of the settings form. The trigger needs aria-label of its own — a coloured square with no
text is announced as "button" and nothing more.
Naming
A palette is the one control where the value is literally invisible to some of its users, so the name does the whole job:
{ value: '#0b7285', name: 'Teal' } // announced as "Teal, radio button, selected"
{ value: '#0b7285', name: '#0b7285' } // announced as a string of hex digitsUse the name the product already uses — "Critical", "Awaiting histology", "Teal". A hex code read aloud is not a colour, it is six characters.
Keyboard
The group is a radio group, not a row of buttons, and that is the substance of the component rather than a detail of it. One tab stop for the whole palette, arrows to move within it:
| Key | Does |
|---|---|
| Tab | Enters the group at the selected swatch, or the first if nothing is selected. One press leaves it. |
| ← → | Previous and next swatch, wrapping round the ends. |
| ↑ ↓ | Up and down a row, by columns. Stops at the ends rather than wrapping. |
| Home End | First and last swatch. |
Selection follows focus, as the APG specifies for a radio group: arrowing onto a swatch chooses it. That is safe here because choosing is reversible and costs nothing.
columns is the one number the component cannot infer. Nothing in the DOM says how wide a row of
a CSS grid is, so ↓ has to be told; pass the same number you would have passed to the
grid, and it drives both.
Eleven swatches as eleven <button>s is eleven tab stops and eleven announcements of "button",
with nothing saying they are alternatives. role="radiogroup" with a roving tabindex is a
press to enter, a press to leave, and "3 of 11" on the way through.
Colour is never the only signal
A selected swatch gets a check mark and a 2px brand ring, not just "the one that looks highlighted". Highlighting a colour with a colour is circular, and it fails for the users most likely to be using a colour picker carefully in the first place.
The same rule is why the palette is a list of named options rather than a gradient canvas. A 2-D saturation and value area has no keyboard story worth the name and no way to announce where you are in it — and the thing people actually do is pick the colour for a tag from a set the product chose.
API Reference
ColorPicker
Prop
Type
ColorSwatch
Prop
Type