Qure UI
Components

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.

'use client'import * as React from 'react'import { ColorPicker, ColorSwatch, type ColorOption } from '@/registry/qure/ui/color-picker'/* Named, because a screen reader reading out "#0b7285" tells the reader   nothing they can act on. The names are the ones the ward already uses. */const TAG_COLOURS: ColorOption[] = [  { value: '#0b7285', name: 'Teal' },  { value: '#1864ab', name: 'Blue' },  { value: '#5f3dc4', name: 'Violet' },  { value: '#a61e4d', name: 'Magenta' },  { value: '#c92a2a', name: 'Red' },  { value: '#d9480f', name: 'Orange' },  { value: '#e67700', name: 'Amber' },  { value: '#5c940d', name: 'Olive' },  { value: '#2b8a3e', name: 'Green' },  { value: '#495057', name: 'Slate' },  { value: '#f1f3f5', name: 'Paper' },  { value: 'transparent', name: 'No colour' },]export default function ColorPickerDemo() {  const [colour, setColour] = React.useState('#0b7285')  const chosen = TAG_COLOURS.find((c) => c.value === colour)  return (    <div className="flex flex-col gap-4">      <ColorPicker        label="Tag colour"        colors={TAG_COLOURS}        value={colour}        onValueChange={setColour}      />      <p className="flex items-center gap-2 text-body-small text-muted-foreground">        <ColorSwatch color={colour} size="sm" />        Patients tagged <strong className="font-semibold">Awaiting histology</strong> will show as{' '}        {chosen?.name.toLowerCase()}.      </p>    </div>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/color-picker.json

Usage

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

TealTealTealTeal
WhiteNo colourTeal, 35 per cent
Teal, selectedPaper, selectedNo colour, selected
'use client'import { ColorSwatch } from '@/registry/qure/ui/color-picker'export default function ColorPickerSwatch() {  return (    <div className="flex flex-col gap-6">      <div className="flex items-center gap-3">        <ColorSwatch color="#0b7285" size="sm" name="Teal" />        <ColorSwatch color="#0b7285" size="md" name="Teal" />        <ColorSwatch color="#0b7285" size="lg" name="Teal" />        <ColorSwatch color="#0b7285" size="lg" shape="square" name="Teal" />      </div>      <div className="flex items-center gap-3">        {/* The two cases a bare background-color gets wrong: a near-white fill            with no edge, and a fill you cannot tell from an empty box. */}        <ColorSwatch color="#ffffff" size="lg" name="White" />        <ColorSwatch color="transparent" size="lg" name="No colour" />        <ColorSwatch color="rgba(11, 114, 133, 0.35)" size="lg" name="Teal, 35 per cent" />      </div>      <div className="flex items-center gap-3">        {/* Selected on a dark fill, a light fill and a translucent one — the            check picks its own ink so it never disappears into the colour. */}        <ColorSwatch color="#0b7285" size="lg" selected name="Teal, selected" />        <ColorSwatch color="#f1f3f5" size="lg" selected name="Paper, selected" />        <ColorSwatch color="transparent" size="lg" selected name="No colour, selected" />      </div>    </div>  )}

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 transparent it 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

#e67700
'use client'import * as React from 'react'import { ColorPicker, type ColorOption } from '@/registry/qure/ui/color-picker'/* Reading-list priorities. Four options, so four columns and one row — the   arrow keys read `columns` to know how wide a row is. */const PRIORITY_COLOURS: ColorOption[] = [  { value: '#c92a2a', name: 'Critical' },  { value: '#e67700', name: 'Urgent' },  { value: '#1864ab', name: 'Routine' },  { value: '#495057', name: 'Deferred' },]export default function ColorPickerCustomExample() {  const [colour, setColour] = React.useState('#e67700')  return (    <div className="w-64">      <ColorPicker        label="Priority colour"        colors={PRIORITY_COLOURS}        columns={4}        shape="square"        value={colour}        onValueChange={setColour}        allowCustom        customLabel="Or a hex colour"      />    </div>  )}

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

Awaiting histology
'use client'import * as React from 'react'import { Badge } from '@/registry/qure/ui/badge'import { ColorPicker, ColorSwatch, type ColorOption } from '@/registry/qure/ui/color-picker'import { Popover, PopoverContent, PopoverTrigger } from '@/registry/qure/ui/popover'const TAG_COLOURS: ColorOption[] = [  { value: '#0b7285', name: 'Teal' },  { value: '#1864ab', name: 'Blue' },  { value: '#5f3dc4', name: 'Violet' },  { value: '#a61e4d', name: 'Magenta' },  { value: '#c92a2a', name: 'Red' },  { value: '#2b8a3e', name: 'Green' },]export default function ColorPickerPopover() {  const [colour, setColour] = React.useState('#5f3dc4')  return (    <div className="flex items-center gap-3">      <Popover>        {/* The trigger needs a name of its own — a coloured square with no            label is announced as "button" and nothing else. Borrowing the            picker's own item class gives it the same hover and focus ring as            the swatches it opens. */}        <PopoverTrigger          aria-label="Choose tag colour"          className="cn-color-picker-item cn-color-picker-item-circle grid place-items-center outline-none"        >          <ColorSwatch color={colour} size="md" />        </PopoverTrigger>        <PopoverContent align="start" className="w-auto">          <ColorPicker            label="Tag colour"            colors={TAG_COLOURS}            columns={3}            value={colour}            onValueChange={setColour}          />        </PopoverContent>      </Popover>      <Badge tone="neutral" variant="outline">        Awaiting histology      </Badge>    </div>  )}

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 digits

Use 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:

KeyDoes
TabEnters 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 EndFirst 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

On this page