Forms and inputsColour picker
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.
Book a callColour picker
Pick a colour by eye or by hex, check its contrast, and let the agent fix it or make a scale from it. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add color-pickerReact
npx designme add color-picker --reactAstro
npx designme add color-picker --astroThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.
shadcn CLI, React
npx shadcn@latest add https://designme.dev/r/shadcn/color-picker.json shadcn CLI, Astro
npx shadcn@latest add https://designme.dev/r/shadcn/astro/color-picker.json In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.
About
What it is for and what it does.
A colour picker with its accessibility check built in. Pick by eye in a saturation and brightness field with hue and opacity rails, type a hex, or press a swatch. Under it, the colour's contrast with white and with ink text shows as a ratio and as Pass or Fail. Two agent actions finish the job: Suggest an accessible shade glides to the nearest darker shade where white text passes, and Make a scale fills a 50 to 900 ramp from the colour.
- For brand or accent colours in a design tool or a theme editor, where the colour will carry text.
- When people pick colours by eye and need to know at once whether text on them will be readable.
- To turn one picked colour into a scale for a design system.
- A saturation and brightness field, a hue rail and an optional opacity rail, each with a knob that shows the colour under it.
- A hex field that takes three or six digits, with or without #; Enter keeps a valid colour, a wrong one is marked in red, and Escape puts back the one before.
- Swatches that glide the picker to their colour; the one matching the colour is marked.
- Contrast with white and with ink text (
ink, #111111 by default) as a WCAG 2 ratio and a Pass or Fail tag at 4.5:1. - Suggest an accessible shade, the primary button (on the accent, so it follows yours), keeps the hue and saturation and darkens the colour to the nearest shade where white text passes; once white text passes the button says so and is disabled.
- Make a scale fills ten steps from 50 to 900 one after another; the picked colour takes the step nearest its lightness, and each step shows its hex on hover and focus.
onScalereceives the steps.
- The field and both rails are sliders (role="slider") with values in words: "Saturation 62%, brightness 62%", "172 degrees", "100% opaque".
- On the field, Left and Right change saturation and Up and Down brightness; on the rails the arrows move one step. Shift moves ten, Home and End go to the ends.
- The hex field has a label and aria-invalid when what was typed isn't a colour.
- Pass and Fail are words, not only colours; the contrast lines are a polite live region.
- A visually hidden live region says what the agent did, like "Moved to #1D6FD6, white text passes".
Usage
Import it in React and drive it from your own state, or place it in an Astro page.
React
import { ColorPicker } from '@/components/designme/color-picker/ColorPicker';React example
The demo above as a React component, 23 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: a bright blue fails with white text, the agent
// darkens it until it passes, then makes a scale from it.
import { useRef, useState } from 'react';
import { ColorPicker } from './ColorPicker';
import { useStory } from '../lib/useScript';
export default function ColorPickerDemo() {
const ref = useRef<HTMLDivElement>(null);
const [color, setColor] = useState('#3c9e91');
const [alpha, setAlpha] = useState(1);
useStory(ref, async ({ wait, press }) => {
await wait(1500); press('.dm-cpk-sw button');
await wait(2500); press('.dm-cpk-act .dm-soft');
await wait(2500); press('.dm-cpk-act .dm-ghost');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<ColorPicker value={color} onValueChange={setColor} alpha={alpha} onAlphaChange={setAlpha} />
</div>
);
}
Astro
In a page in src/pages. It plays the demo above; change its color-picker.js to show your own data. Attributes like class go on its root element.
---
import ColorPicker from '../components/designme/color-picker/ColorPicker.astro';
---
<ColorPicker />
<ColorPicker data-style="soft" class="my-class" />Props
Everything ColorPicker takes.
| Prop | Type | What it does |
|---|---|---|
value | string | A hex colour, like "#2f8cff" |
defaultValue | string | |
onValueChange | (hex: string) => void | |
alpha | number | Opacity, 0 to 1. Leave both out to hide the opacity rail |
onAlphaChange | (alpha: number) => void | |
swatches | string[] | |
ink | string | The dark text colour the contrast is checked against (#111111) |
onScale | (scale: { step: number; hex: string; base: boolean }[]) => void | Called when a scale is made, with its ten steps |
className | string |
Code
The full source, as the CLI installs it.
ColorPicker.tsxReact · 135 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { cx } from '../lib/cx';
import { useReducedMotion } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Spin, Tip } from '../lib/motion';
import '../designme.css';
import './color-picker.css';
type RGB = [number, number, number];
const hsv2rgb = (h: number, s: number, v: number): RGB => { const f = (n: number) => { const k = (n + h / 60) % 6; return v - v * s * Math.max(0, Math.min(k, 4 - k, 1)); }; return [f(5), f(3), f(1)].map(x => Math.round(x * 255)) as RGB; };
const rgb2hsv = ([r, g, b]: RGB): [number, number, number] => { const [R, G, B] = [r, g, b].map(x => x / 255), mx = Math.max(R, G, B), mn = Math.min(R, G, B), d = mx - mn; let h = 0;
if (d) h = mx === R ? ((G - B) / d) % 6 : mx === G ? (B - R) / d + 2 : (R - G) / d + 4; return [(h * 60 + 360) % 360, mx ? d / mx : 0, mx]; };
const toHex = (c: RGB) => '#' + c.map(x => x.toString(16).padStart(2, '0')).join('');
/** Reads #rgb or #rrggbb (with or without #), or returns null */
export const parseHex = (t: string): RGB | null => { const m = t.trim().replace(/^#/, '').match(/^([0-9a-f]{3}|[0-9a-f]{6})$/i); if (!m) return null; let s = m[1]; if (s.length === 3) s = [...s].map(x => x + x).join(''); return [0, 2, 4].map(i => parseInt(s.slice(i, i + 2), 16)) as RGB; };
const lum = (c: RGB) => { const [R, G, B] = c.map(x => { x /= 255; return x <= .03928 ? x / 12.92 : Math.pow((x + .055) / 1.055, 2.4); }); return .2126 * R + .7152 * G + .0722 * B; };
/** The WCAG 2 contrast ratio of two colours, from 1 to 21 */
export const contrast = (a: RGB, b: RGB) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
/** A 50 to 900 scale from a colour's hue and saturation, with the colour itself at the step nearest its lightness */
export function makeScale(color: string) {
const c = parseHex(color); if (!c) return [];
const [h, s] = rgb2hsv(c), L = [97, 93, 85, 74, 62, 50, 41, 33, 25, 18];
const out = L.map(l => { const sat = Math.min(100, s * 100 * (l > 80 ? .9 : 1)), C = (1 - Math.abs(2 * l / 100 - 1)) * sat / 100, X = C * (1 - Math.abs((h / 60) % 2 - 1)), m = l / 100 - C / 2;
const [r1, g1, b1] = h < 60 ? [C, X, 0] : h < 120 ? [X, C, 0] : h < 180 ? [0, C, X] : h < 240 ? [0, X, C] : h < 300 ? [X, 0, C] : [C, 0, X];
return toHex([r1, g1, b1].map(x => Math.round((x + m) * 255)) as RGB); });
const near = out.reduce((b, x, i) => Math.abs(lum(parseHex(x)!) - lum(c)) < Math.abs(lum(parseHex(out[b])!) - lum(c)) ? i : b, 0);
out[near] = toHex(c);
return out.map((hex, i) => ({ step: STEPS[i], hex, base: i === near }));
}
export interface ColorPickerProps {
/** A hex colour, like "#2f8cff" */
value?: string;
defaultValue?: string;
onValueChange?: (hex: string) => void;
/** Opacity, 0 to 1. Leave both out to hide the opacity rail */
alpha?: number;
onAlphaChange?: (alpha: number) => void;
swatches?: string[];
/** The dark text colour the contrast is checked against (#111111) */
ink?: string;
/** Called when a scale is made, with its ten steps */
onScale?: (scale: { step: number; hex: string; base: boolean }[]) => void;
className?: string;
}
/** A saturation and brightness field, hue and opacity rails, a hex field and swatches, with the colour's contrast against white and ink text in words, and two agent actions: a nearby shade that passes, and a 50 to 900 scale. */
export function ColorPicker({ value, defaultValue = '#3c9e91', onValueChange, alpha, onAlphaChange, swatches = ['#2f8cff', '#7c4dff', '#ff4d6d', '#ff9f1c', '#22c993', '#00a2bf', '#1f2937', '#f5c400'], ink = '#111111', onScale, className }: ColorPickerProps) {
const [hsv, setHsv] = useState(() => rgb2hsv(parseHex(value ?? defaultValue) ?? [47, 140, 255]));
const [a, setA] = useState(alpha ?? 1);
const [text, setText] = useState('');
const [bad, setBad] = useState(false);
const [busy, setBusy] = useState<'' | 'fix' | 'scale'>('');
const [scale, setScale] = useState<{ step: number; hex: string; base: boolean }[]>([]);
const [said, setSaid] = useState('');
const reduce = useReducedMotion();
const editing = useRef(false), before = useRef('');
const [H, S, V] = hsv, rgb = hsv2rgb(H, S, V), hex = toHex(rgb);
// a value from outside moves the picker, unless it is the colour already showing
useEffect(() => { const p = value && parseHex(value); if (p && toHex(p) !== hex) setHsv(rgb2hsv(p)); }, [value]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { if (alpha !== undefined) setA(alpha); }, [alpha]);
useEffect(() => { if (!editing.current) { setText(hex.slice(1).toUpperCase()); setBad(false); } }, [hex]);
const set = (h: number, s: number, v: number) => { setHsv([h, s, v]); const n = toHex(hsv2rgb(h, s, v)); if (n !== hex) onValueChange?.(n); };
const setAlpha = (x: number) => { setA(x); onAlphaChange?.(x); };
const glide = (to: [number, number, number], ms = 500) => new Promise<void>(res => {
const [h0, s0, v0] = hsv, dh = ((to[0] - h0 + 540) % 360) - 180, t0 = performance.now();
const f = (t: number) => { const p = Math.min(1, (t - t0) / (reduce ? 1 : ms)), e = 1 - Math.pow(1 - p, 3); set((h0 + dh * e + 360) % 360, s0 + (to[1] - s0) * e, v0 + (to[2] - v0) * e); if (p < 1) requestAnimationFrame(f); else res(); };
requestAnimationFrame(f);
});
const drag = (apply: (x: number, y: number) => void) => (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button) return; e.preventDefault(); const el = e.currentTarget; el.setPointerCapture(e.pointerId); el.querySelector<HTMLElement>('.dm-cpk-k')?.focus({ preventScroll: true });
const mv = (ev: { clientX: number; clientY: number }) => { const b = el.getBoundingClientRect(); apply(Math.max(0, Math.min(1, (ev.clientX - b.left) / b.width)), Math.max(0, Math.min(1, (ev.clientY - b.top) / b.height))); };
mv(e); el.onpointermove = mv; el.onpointerup = () => { el.onpointermove = null; };
};
const cl = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x));
const keys = (fn: (dx: number | 'min' | 'max', dy: number) => void) => (e: React.KeyboardEvent) => {
const n = e.shiftKey ? 10 : 1, m = ({ ArrowLeft: [-n, 0], ArrowRight: [n, 0], ArrowUp: [0, n], ArrowDown: [0, -n], Home: ['min', 0], End: ['max', 0] } as Record<string, [number | 'min' | 'max', number]>)[e.key];
if (!m) return; e.preventDefault(); fn(m[0], m[1]);
};
const passW = contrast(rgb, [255, 255, 255]) >= 4.5;
// the agent: the same hue and saturation, darkened until white text passes 4.5:1
const suggest = async () => {
let v = V; while (v > 0 && contrast(hsv2rgb(H, S, v), [255, 255, 255]) < 4.6) v -= .005;
setBusy('fix'); await new Promise(r => setTimeout(r, 500)); await glide([H, S, v], 700); setBusy(''); setSaid(`Moved to ${toHex(hsv2rgb(H, S, v)).toUpperCase()}, white text passes`);
};
const make = async () => { setBusy('scale'); setScale([]); await new Promise(r => setTimeout(r, 400)); const sc = makeScale(hex); setScale(sc); setBusy(''); onScale?.(sc); setSaid(`Scale made, ${hex.toUpperCase()} is step ${sc.find(x => x.base)?.step}`); };
const showAlpha = alpha !== undefined || onAlphaChange !== undefined;
const style = { '--dm-hc': `hsl(${H} 100% 50%)`, '--dm-c': a < 1 ? `rgb(${rgb.join(' ')} / ${a})` : hex } as CSSProperties;
return (
<div data-dm="" className={cx('dm-cpk dm-panel', className)} style={style}>
<div className="dm-cpk-sv" onPointerDown={drag((x, y) => set(H, x, 1 - y))}>
<span className="dm-cpk-k" role="slider" tabIndex={0} aria-label="Saturation and brightness" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(S * 100)}
aria-valuetext={`Saturation ${Math.round(S * 100)}%, brightness ${Math.round(V * 100)}%`} style={{ left: S * 100 + '%', top: (1 - V) * 100 + '%', background: hex }}
onKeyDown={keys((dx, dy) => dx === 'min' ? set(H, 0, V) : dx === 'max' ? set(H, 1, V) : set(H, cl(S + dx / 100, 0, 1), cl(V + dy / 100, 0, 1)))} />
</div>
<div className="dm-cpk-rail dm-cpk-hue" onPointerDown={drag(x => set(x * 359, S, V))}>
<span className="dm-cpk-k" role="slider" tabIndex={0} aria-label="Hue" aria-valuemin={0} aria-valuemax={359} aria-valuenow={Math.round(H)} aria-valuetext={Math.round(H) + ' degrees'}
style={{ left: H / 359 * 100 + '%', background: `hsl(${H} 100% 50%)` }} onKeyDown={keys((dx, dy) => set(dx === 'min' ? 0 : dx === 'max' ? 359 : cl(H + (dx || dy), 0, 359), S, V))} />
</div>
{showAlpha && <div className="dm-cpk-rail dm-cpk-al" onPointerDown={drag(x => setAlpha(x))}><i />
<span className="dm-cpk-k" role="slider" tabIndex={0} aria-label="Opacity" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(a * 100)} aria-valuetext={Math.round(a * 100) + '% opaque'}
style={{ left: a * 100 + '%', background: hex }} onKeyDown={keys((dx, dy) => setAlpha(dx === 'min' ? 0 : dx === 'max' ? 1 : cl(a + (dx || dy) / 100, 0, 1)))} />
</div>}
<div className="dm-cpk-row">
<span className="dm-cpk-pv" aria-hidden="true"><i /></span>
<label className="dm-cpk-hx" aria-invalid={bad || undefined}><span className="dm-muted">#</span>
<input className="dm-num" spellCheck={false} autoComplete="off" maxLength={7} aria-label="Hex colour" value={text}
onFocus={e => { editing.current = true; before.current = text; e.currentTarget.select(); }}
onChange={e => { setText(e.target.value.replace(/[^0-9a-f#]/gi, '')); setBad(false); }}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); const p = parseHex(text); if (p) set(...rgb2hsv(p)); else setBad(true); } if (e.key === 'Escape') { setText(before.current); setBad(false); } }}
onBlur={() => { editing.current = false; const p = parseHex(text); if (p) set(...rgb2hsv(p)); else { setText(before.current); setBad(false); } }} />
</label>
{showAlpha && <span className="dm-cpk-a dm-num dm-muted">{Math.round(a * 100)}%</span>}
</div>
<div className="dm-cpk-sw" role="group" aria-label="Swatches">
{swatches.map(s => <Tip key={s} label={s.toUpperCase()}><button type="button" style={{ ['--dm-s' as string]: s }} aria-label={s} aria-pressed={toHex(parseHex(s) ?? [0, 0, 0]) === hex} onClick={() => { const p = parseHex(s); if (p) void glide(rgb2hsv(p)); }} /></Tip>)}
</div>
<div className="dm-cpk-cr" aria-live="polite">
{[['#ffffff', 'White text'], [ink, 'Ink text']].map(([on, label]) => { const x = contrast(rgb, parseHex(on)!), ok = x >= 4.5;
return <div key={label} className="dm-cpk-ln"><span className="dm-cpk-aa" style={{ color: on }}>Aa</span><span className="dm-t">{label}</span><span className="dm-num dm-r">{x.toFixed(1)}:1</span><span className={cx('dm-tag', ok ? 'dm-ok' : 'dm-bad')}>{ok ? 'Pass' : 'Fail'}</span></div>; })}
</div>
<div className="dm-cpk-act">
<button type="button" className="dm-pill dm-sm dm-cpk-fix" disabled={passW || !!busy} onClick={suggest}>{busy === 'fix' ? <><Spin />Finding a shade</> : <><Icon name="wand" size="xs" />{passW ? 'White text passes' : 'Suggest an accessible shade'}</>}</button>
<button type="button" className="dm-pill dm-ghost dm-sm" disabled={!!busy} onClick={make}>{busy === 'scale' ? <><Spin />Making a scale</> : <><Icon name="layers" size="xs" />Make a scale</>}</button>
</div>
<div className="dm-cpk-sc" role="group" aria-label="Scale">
{scale.map((x, i) => <Tip key={x.step} label={`${x.step}: ${x.hex.toUpperCase()}`}><button type="button" className={cx(x.base && 'dm-base', 'dm-enter')} style={{ animationDelay: i * 60 + 'ms' }} aria-label={`${x.step}, ${x.hex.toUpperCase()}`}><i style={{ background: x.hex }} /><span className="dm-num">{x.step}</span></button></Tip>)}
</div>
<p className="dm-cpk-sr" aria-live="polite">{said}</p>
</div>
);
}
color-picker.cssReact · 46 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Colour picker ---------- */
/* a field for saturation and brightness, rails for hue and opacity, the hex, swatches, and the colour's contrast with white
and ink text in words; the agent can glide it to a shade that passes or make a 50 to 900 scale from it */
.dm-cpk{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3);--dm-h:210;--dm-hc:hsl(210 100% 50%);--dm-c:#2f8cff}
.dm-cpk-sv{position:relative;height:132px;border-radius:var(--dm-r-in);background:linear-gradient(to top,#000,transparent),linear-gradient(to right,#fff,var(--dm-hc));touch-action:none;cursor:crosshair;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-cpk-rail{position:relative;height:12px;margin:0 8px;border-radius:calc(6px * var(--dm-rk));touch-action:none;cursor:pointer}
.dm-cpk-hue{background:linear-gradient(to right,#f00,#ff0 17%,#0f0 33%,#0ff 50%,#00f 67%,#f0f 83%,#f00)}
.dm-cpk-al{background:repeating-conic-gradient(#e6e6e6 0 25%,#fff 0 50%) 0 0/8px 8px}
.dm-cpk-al i{position:absolute;inset:0;border-radius:inherit;background:linear-gradient(to right,transparent,var(--dm-c))}
/* the knobs: white rings that show the colour under them */
.dm-cpk-k{position:absolute;left:0;top:0;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;box-shadow:0 0 0 2px #fff,0 0 0 3px rgba(0,0,0,.18),0 1px 3px rgba(0,0,0,.25);cursor:grab;z-index:1}
.dm-cpk-rail .dm-cpk-k{top:50%}
.dm-cpk-k:focus-visible{outline-offset:4px;border-radius:50%}
.dm-cpk-row{display:flex;align-items:center;gap:var(--dm-s-2)}
.dm-cpk-pv{position:relative;width:32px;height:32px;flex:none;border-radius:var(--dm-r-field);overflow:hidden;background:repeating-conic-gradient(#e6e6e6 0 25%,#fff 0 50%) 0 0/8px 8px;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-cpk-pv i{position:absolute;inset:0;background:var(--dm-c)}
.dm-cpk-hx{flex:1;min-width:0;display:flex;align-items:center;gap:2px;height:32px;padding:0 10px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);font-size:13px;cursor:text;transition:box-shadow var(--dm-t-fast)}
.dm-cpk-hx:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-cpk-hx[aria-invalid="true"]{box-shadow:inset 0 0 0 1px var(--dm-bad)}
.dm-cpk-hx input{flex:1;min-width:0;border:0;outline:0;padding:0;background:none;font:inherit;color:var(--dm-fg);text-transform:uppercase;letter-spacing:.02em}
@supports (corner-shape:squircle){.dm-cpk-hx,.dm-cpk-pv{border-radius:var(--dm-r-field-sq)}}
.dm-cpk-a{font-size:12.5px;min-width:36px;text-align:right}
.dm-cpk-sw{display:flex;gap:6px;flex-wrap:wrap}
.dm-cpk-sw button{width:24px;height:24px;border-radius:calc(7px * var(--dm-rk));background:var(--dm-s);box-shadow:inset 0 0 0 1px rgba(0,0,0,.08);transition:transform .15s var(--dm-ease),box-shadow var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-cpk-sw button:hover{transform:scale(1.08)}}
.dm-cpk-sw button[aria-pressed="true"]{box-shadow:0 0 0 2px var(--dm-surface),0 0 0 3px color-mix(in srgb,var(--dm-pick-on) 70%,transparent)}
.dm-cpk-cr{display:flex;flex-direction:column;gap:2px}
.dm-cpk-ln{display:flex;align-items:center;gap:var(--dm-s-2);font-size:12.5px;color:var(--dm-fg2);padding:2px 0}
.dm-cpk-aa{width:30px;height:22px;flex:none;display:grid;place-items:center;border-radius:calc(6px * var(--dm-rk));background:var(--dm-c);font-size:12px;font-weight:500;box-shadow:inset 0 0 0 1px rgba(0,0,0,.06)}
.dm-cpk-ln .dm-t{flex:1}
.dm-cpk-ln .dm-r{color:var(--dm-fg)}
.dm-cpk-ln .dm-tag{min-width:40px;justify-content:center}
.dm-cpk-act{display:flex;gap:6px;flex-wrap:wrap}
/* the suggestion is an accent highlight, the accent's tint with accent text, like an event in Event calendar (owner, 4 Nov) */
.dm-cpk-fix:not(:disabled){background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent));box-shadow:none}
@media (hover:hover) and (pointer:fine){.dm-cpk-fix:not(:disabled):hover{background:color-mix(in srgb,var(--dm-accent) 16%,transparent)}}
.dm-cpk-sc{display:grid;grid-template-columns:repeat(10,minmax(0,1fr));gap:2px;min-height:38px}
.dm-cpk-sc button{display:flex;flex-direction:column;align-items:center;gap:3px;font-size:10.5px;color:var(--dm-muted);min-width:0}
.dm-cpk-sc button i{display:block;width:100%;height:22px;border-radius:calc(4px * var(--dm-rk));box-shadow:inset 0 0 0 1px rgba(0,0,0,.06)}
.dm-cpk-sc button:first-child i{border-radius:calc(7px * var(--dm-rk)) calc(4px * var(--dm-rk)) calc(4px * var(--dm-rk)) calc(7px * var(--dm-rk))}
.dm-cpk-sc button:last-child i{border-radius:calc(4px * var(--dm-rk)) calc(7px * var(--dm-rk)) calc(7px * var(--dm-rk)) calc(4px * var(--dm-rk))}
.dm-cpk-sc button.dm-base span{color:var(--dm-fg);font-weight:500}
.dm-cpk-sc:empty{background:repeating-linear-gradient(90deg,var(--dm-chip) 0 calc(10% - 2px),transparent calc(10% - 2px) 10%) 0 0/100% 22px no-repeat}
.dm-cpk-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
ColorPicker.astroAstro · 38 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Colour picker: Pick a colour by eye or by hex, check its contrast, and let the agent fix it or make a scale from it.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change color-picker.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './color-picker.css';
export interface Props extends HTMLAttributes<'div'> {
/** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="color-picker" {...rest}>
<div class="stage-in"><Fragment is:raw>
<div class="cpk panel">
<div class="cpk-sv"><span class="cpk-k" role="slider" tabindex="0" aria-label="Saturation and brightness" aria-valuemin="0" aria-valuemax="100"></span></div>
<div class="cpk-rail cpk-hue"><span class="cpk-k" role="slider" tabindex="0" aria-label="Hue" aria-valuemin="0" aria-valuemax="359"></span></div>
<div class="cpk-rail cpk-al"><i></i><span class="cpk-k" role="slider" tabindex="0" aria-label="Opacity" aria-valuemin="0" aria-valuemax="100"></span></div>
<div class="cpk-row">
<span class="cpk-pv" aria-hidden="true"><i></i></span>
<label class="cpk-hx"><span class="muted">#</span><input class="num" spellcheck="false" autocomplete="off" maxlength="7" aria-label="Hex colour"></label>
<span class="cpk-a num muted"></span>
</div>
<div class="cpk-sw" role="group" aria-label="Swatches"></div>
<div class="cpk-cr" aria-live="polite"><div class="cpk-ln" data-on="#ffffff"><span class="cpk-aa">Aa</span><span class="t">White text</span><span class="num r"></span><span class="tag"></span></div><div class="cpk-ln" data-on="ink"><span class="cpk-aa">Aa</span><span class="t">Ink text</span><span class="num r"></span><span class="tag"></span></div></div>
<div class="cpk-act"><button class="pill sm cpk-fix"><svg class="i xs"><use href="#i-wand"/></svg>Suggest an accessible shade</button><button class="pill ghost sm cpk-mk"><svg class="i xs"><use href="#i-layers"/></svg>Make a scale</button></div>
<div class="cpk-sc" role="group" aria-label="Scale"></div>
<p class="cpk-sr" aria-live="polite"></p>
</div>
</Fragment></div>
</div>
<script>
import './color-picker.js';
</script>
color-picker.htmlHTML · 20 lines
<div class="card" data-c="color-picker">
<div class="stage-in">
<div class="cpk panel">
<div class="cpk-sv"><span class="cpk-k" role="slider" tabindex="0" aria-label="Saturation and brightness" aria-valuemin="0" aria-valuemax="100"></span></div>
<div class="cpk-rail cpk-hue"><span class="cpk-k" role="slider" tabindex="0" aria-label="Hue" aria-valuemin="0" aria-valuemax="359"></span></div>
<div class="cpk-rail cpk-al"><i></i><span class="cpk-k" role="slider" tabindex="0" aria-label="Opacity" aria-valuemin="0" aria-valuemax="100"></span></div>
<div class="cpk-row">
<span class="cpk-pv" aria-hidden="true"><i></i></span>
<label class="cpk-hx"><span class="muted">#</span><input class="num" spellcheck="false" autocomplete="off" maxlength="7" aria-label="Hex colour"></label>
<span class="cpk-a num muted"></span>
</div>
<div class="cpk-sw" role="group" aria-label="Swatches"></div>
<div class="cpk-cr" aria-live="polite"><div class="cpk-ln" data-on="#ffffff"><span class="cpk-aa">Aa</span><span class="t">White text</span><span class="num r"></span><span class="tag"></span></div><div class="cpk-ln" data-on="ink"><span class="cpk-aa">Aa</span><span class="t">Ink text</span><span class="num r"></span><span class="tag"></span></div></div>
<div class="cpk-act"><button class="pill sm cpk-fix"><svg class="i xs"><use href="#i-wand"/></svg>Suggest an accessible shade</button><button class="pill ghost sm cpk-mk"><svg class="i xs"><use href="#i-layers"/></svg>Make a scale</button></div>
<div class="cpk-sc" role="group" aria-label="Scale"></div>
<p class="cpk-sr" aria-live="polite"></p>
</div>
</div>
</div>
color-picker.cssHTML · 45 lines
/* ---------- Colour picker ---------- */
/* a field for saturation and brightness, rails for hue and opacity, the hex, swatches, and the colour's contrast with white
and ink text in words; the agent can glide it to a shade that passes or make a 50 to 900 scale from it */
.cpk{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3);--dm-h:210;--dm-hc:hsl(210 100% 50%);--dm-c:#2f8cff}
.cpk-sv{position:relative;height:132px;border-radius:var(--dm-r-in);background:linear-gradient(to top,#000,transparent),linear-gradient(to right,#fff,var(--dm-hc));touch-action:none;cursor:crosshair;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent)}
.cpk-rail{position:relative;height:12px;margin:0 8px;border-radius:calc(6px * var(--dm-rk));touch-action:none;cursor:pointer}
.cpk-hue{background:linear-gradient(to right,#f00,#ff0 17%,#0f0 33%,#0ff 50%,#00f 67%,#f0f 83%,#f00)}
.cpk-al{background:repeating-conic-gradient(#e6e6e6 0 25%,#fff 0 50%) 0 0/8px 8px}
.cpk-al i{position:absolute;inset:0;border-radius:inherit;background:linear-gradient(to right,transparent,var(--dm-c))}
/* the knobs: white rings that show the colour under them */
.cpk-k{position:absolute;left:0;top:0;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;box-shadow:0 0 0 2px #fff,0 0 0 3px rgba(0,0,0,.18),0 1px 3px rgba(0,0,0,.25);cursor:grab;z-index:1}
.cpk-rail .cpk-k{top:50%}
.cpk-k:focus-visible{outline-offset:4px;border-radius:50%}
.cpk-row{display:flex;align-items:center;gap:var(--dm-s-2)}
.cpk-pv{position:relative;width:32px;height:32px;flex:none;border-radius:var(--dm-r-field);overflow:hidden;background:repeating-conic-gradient(#e6e6e6 0 25%,#fff 0 50%) 0 0/8px 8px;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent)}
.cpk-pv i{position:absolute;inset:0;background:var(--dm-c)}
.cpk-hx{flex:1;min-width:0;display:flex;align-items:center;gap:2px;height:32px;padding:0 10px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);font-size:13px;cursor:text;transition:box-shadow var(--dm-t-fast)}
.cpk-hx:focus-within{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.cpk-hx[aria-invalid="true"]{box-shadow:inset 0 0 0 1px var(--dm-bad)}
.cpk-hx input{flex:1;min-width:0;border:0;outline:0;padding:0;background:none;font:inherit;color:var(--dm-fg);text-transform:uppercase;letter-spacing:.02em}
@supports (corner-shape:squircle){.cpk-hx,.cpk-pv{border-radius:var(--dm-r-field-sq)}}
.cpk-a{font-size:12.5px;min-width:36px;text-align:right}
.cpk-sw{display:flex;gap:6px;flex-wrap:wrap}
.cpk-sw button{width:24px;height:24px;border-radius:calc(7px * var(--dm-rk));background:var(--dm-s);box-shadow:inset 0 0 0 1px rgba(0,0,0,.08);transition:transform .15s var(--dm-ease),box-shadow var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.cpk-sw button:hover{transform:scale(1.08)}}
.cpk-sw button[aria-pressed="true"]{box-shadow:0 0 0 2px var(--dm-surface),0 0 0 3px color-mix(in srgb,var(--dm-pick-on) 70%,transparent)}
.cpk-cr{display:flex;flex-direction:column;gap:2px}
.cpk-ln{display:flex;align-items:center;gap:var(--dm-s-2);font-size:12.5px;color:var(--dm-fg2);padding:2px 0}
.cpk-aa{width:30px;height:22px;flex:none;display:grid;place-items:center;border-radius:calc(6px * var(--dm-rk));background:var(--dm-c);font-size:12px;font-weight:500;box-shadow:inset 0 0 0 1px rgba(0,0,0,.06)}
.cpk-ln .t{flex:1}
.cpk-ln .r{color:var(--dm-fg)}
.cpk-ln .tag{min-width:40px;justify-content:center}
.cpk-act{display:flex;gap:6px;flex-wrap:wrap}
/* the suggestion is an accent highlight, the accent's tint with accent text, like an event in Event calendar (owner, 4 Nov) */
.cpk-fix:not(:disabled){background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent));box-shadow:none}
@media (hover:hover) and (pointer:fine){.cpk-fix:not(:disabled):hover{background:color-mix(in srgb,var(--dm-accent) 16%,transparent)}}
.cpk-sc{display:grid;grid-template-columns:repeat(10,minmax(0,1fr));gap:2px;min-height:38px}
.cpk-sc button{display:flex;flex-direction:column;align-items:center;gap:3px;font-size:10.5px;color:var(--dm-muted);min-width:0}
.cpk-sc button i{display:block;width:100%;height:22px;border-radius:calc(4px * var(--dm-rk));box-shadow:inset 0 0 0 1px rgba(0,0,0,.06)}
.cpk-sc button:first-child i{border-radius:calc(7px * var(--dm-rk)) calc(4px * var(--dm-rk)) calc(4px * var(--dm-rk)) calc(7px * var(--dm-rk))}
.cpk-sc button:last-child i{border-radius:calc(4px * var(--dm-rk)) calc(7px * var(--dm-rk)) calc(7px * var(--dm-rk)) calc(4px * var(--dm-rk))}
.cpk-sc button.base span{color:var(--dm-fg);font-weight:500}
.cpk-sc:empty{background:repeating-linear-gradient(90deg,var(--dm-chip) 0 calc(10% - 2px),transparent calc(10% - 2px) 10%) 0 0/100% 22px no-repeat}
.cpk-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
color-picker.jsHTML · 82 lines
DM.def('color-picker', (r, c) => {
r.innerHTML = r.innerHTML.replace('{wand}', I('wand','xs')).replace('{layers}', I('layers','xs'));
const root = $('.cpk', r), sv = $('.cpk-sv', r), hue = $('.cpk-hue', r), al = $('.cpk-al', r), [kSv, kH, kA] = $$('.cpk-k', r);
const hx = $('.cpk-hx input', r), hxL = $('.cpk-hx', r), aOut = $('.cpk-a', r), sw = $('.cpk-sw', r), lines = $$('.cpk-ln', r), fix = $('.cpk-fix', r), mk = $('.cpk-mk', r), sc = $('.cpk-sc', r), sr = $('.cpk-sr', r);
const INK = '#111111';
// colour maths: HSV for picking, hex for reading, relative luminance for contrast (WCAG 2)
const hsv2rgb = (h, s, v) => { const f = n => { const k = (n + h / 60) % 6; return v - v * s * Math.max(0, Math.min(k, 4 - k, 1)); }; return [f(5), f(3), f(1)].map(x => Math.round(x * 255)); };
const rgb2hsv = ([r1, g1, b1]) => { const [R, G, B] = [r1, g1, b1].map(x => x / 255), mx = Math.max(R, G, B), mn = Math.min(R, G, B), d = mx - mn; let h = 0;
if (d) h = mx === R ? ((G - B) / d) % 6 : mx === G ? (B - R) / d + 2 : (R - G) / d + 4; return [(h * 60 + 360) % 360, mx ? d / mx : 0, mx]; };
const hex = rgb => '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');
const parse = t => { const m = t.trim().replace(/^#/, '').match(/^([0-9a-f]{3}|[0-9a-f]{6})$/i); if (!m) return null; let s = m[1]; if (s.length === 3) s = [...s].map(x => x + x).join(''); return [0, 2, 4].map(i => parseInt(s.slice(i, i + 2), 16)); };
const lum = rgb => { const [R, G, B] = rgb.map(x => { x /= 255; return x <= .03928 ? x / 12.92 : Math.pow((x + .055) / 1.055, 2.4); }); return .2126 * R + .7152 * G + .0722 * B; };
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };
let H = 172, S = .62, V = .62, A = 1;
const SW = ['#2f8cff', '#7c4dff', '#ff4d6d', '#ff9f1c', '#22c993', '#00a2bf', '#1f2937', '#f5c400'];
sw.innerHTML = SW.map(x => `<button style="--dm-s:${x}" aria-label="${x}" data-tip="${x.toUpperCase()}" aria-pressed="false" data-v="${x}"></button>`).join('');
const rgb = () => hsv2rgb(H, S, V);
function paint(from){
const c1 = rgb(), hc = hex(c1);
root.style.setProperty('--dm-hc', `hsl(${H} 100% 50%)`); root.style.setProperty('--dm-c', A < 1 ? `rgb(${c1.join(' ')} / ${A})` : hc);
kSv.style.left = S * 100 + '%'; kSv.style.top = (1 - V) * 100 + '%'; kSv.style.background = hc;
kH.style.left = H / 359 * 100 + '%'; kH.style.background = `hsl(${H} 100% 50%)`;
kA.style.left = A * 100 + '%'; kA.style.background = hc;
kSv.setAttribute('aria-valuenow', Math.round(S * 100)); kSv.setAttribute('aria-valuetext', `Saturation ${Math.round(S * 100)}%, brightness ${Math.round(V * 100)}%`);
kH.setAttribute('aria-valuenow', Math.round(H)); kH.setAttribute('aria-valuetext', Math.round(H) + ' degrees');
kA.setAttribute('aria-valuenow', Math.round(A * 100)); kA.setAttribute('aria-valuetext', Math.round(A * 100) + '% opaque');
if (from !== 'hex' && document.activeElement !== hx){ hx.value = hc.slice(1).toUpperCase(); hxL.removeAttribute('aria-invalid'); }
aOut.textContent = Math.round(A * 100) + '%';
$$('button', sw).forEach(b => b.setAttribute('aria-pressed', b.dataset.v === hc));
// contrast with white and with ink text, as a ratio and in words (4.5:1 for body text)
lines.forEach(l => { const on = l.dataset.on === 'ink' ? INK : '#ffffff', x = ratio(c1, parse(on)), ok = x >= 4.5;
$('.cpk-aa', l).style.color = on; $('.r', l).textContent = x.toFixed(1) + ':1'; const t = $('.tag', l); t.className = 'tag ' + (ok ? 'ok' : 'bad'); t.textContent = ok ? 'Pass' : 'Fail'; });
const passW = ratio(c1, [255, 255, 255]) >= 4.5; fix.disabled = passW; fix.innerHTML = `${I('wand','xs')}${passW ? 'White text passes' : 'Suggest an accessible shade'}`;
}
// dragging: the field sets saturation and brightness, the rails hue and opacity
const drag = (el, set) => { c.on(el, 'pointerdown', e => { if (e.button) return; e.preventDefault(); el.setPointerCapture(e.pointerId); const k = $('.cpk-k', el); k.focus({ preventScroll:true }); const mv = ev => { const b = el.getBoundingClientRect(); set(Math.max(0, Math.min(1, (ev.clientX - b.left) / b.width)), Math.max(0, Math.min(1, (ev.clientY - b.top) / b.height))); paint(); }; mv(e); el.onpointermove = mv; }); c.on(el, 'pointerup', () => el.onpointermove = null); };
drag(sv, (x, y) => { S = x; V = 1 - y; }); drag(hue, x => { H = x * 359; }); drag(al, x => { A = x; });
// the keys: arrows move one step, Shift ten, Home and End to the ends
const keys = (k, fn) => c.on(k, 'keydown', e => { const n = e.shiftKey ? 10 : 1, m = { ArrowLeft:[-n, 0], ArrowRight:[n, 0], ArrowUp:[0, n], ArrowDown:[0, -n], Home:['min', 0], End:['max', 0] }[e.key]; if (!m) return; e.preventDefault(); fn(m); paint(); });
const cl = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
keys(kSv, ([dx, dy]) => { if (dx === 'min') S = 0; else if (dx === 'max') S = 1; else { S = cl(S + dx / 100, 0, 1); V = cl(V + dy / 100, 0, 1); } });
keys(kH, ([dx, dy]) => { H = dx === 'min' ? 0 : dx === 'max' ? 359 : cl(H + (dx || dy), 0, 359); });
keys(kA, ([dx, dy]) => { A = dx === 'min' ? 0 : dx === 'max' ? 1 : cl(A + (dx || dy) / 100, 0, 1); });
// the hex field: Enter keeps a valid colour, Escape puts back the one before, a wrong one is marked
let before = '';
const commit = () => { const p = parse(hx.value); if (p){ [H, S, V] = rgb2hsv(p); hxL.removeAttribute('aria-invalid'); paint(); } else { hxL.setAttribute('aria-invalid', 'true'); } };
c.on(hx, 'focus', () => { before = hx.value; hx.select(); });
c.on(hx, 'input', () => { hx.value = hx.value.replace(/[^0-9a-f#]/gi, ''); hxL.removeAttribute('aria-invalid'); });
c.on(hx, 'keydown', e => { if (e.key === 'Enter'){ e.preventDefault(); commit(); } if (e.key === 'Escape'){ hx.value = before; hxL.removeAttribute('aria-invalid'); } });
c.on(hx, 'blur', () => { if (parse(hx.value)) commit(); else { hx.value = before; hxL.removeAttribute('aria-invalid'); } });
// a swatch glides there
const glide = (to, ms = 500) => { const [h1, s1, v1] = rgb2hsv(to), h0 = H, s0 = S, v0 = V, dh = ((h1 - h0 + 540) % 360) - 180; return tween(0, 1, ms, t => { H = (h0 + dh * t + 360) % 360; S = s0 + (s1 - s0) * t; V = v0 + (v1 - v0) * t; paint(); }); };
$$('button', sw).forEach(b => b.onclick = () => glide(parse(b.dataset.v)));
// the agent: the nearest shade (the same hue and saturation, darker) where white text passes, then a 50 to 900 scale
fix.onclick = () => c.run(async () => {
let v = V; while (v > 0 && ratio(hsv2rgb(H, S, v), [255, 255, 255]) < 4.6) v -= .005;
fix.innerHTML = `<span class="spin"></span>Finding a shade`; await c.wait(500);
await tween(V, v, 700, x => { V = x; paint(); }); sr.textContent = `Moved to ${hex(rgb()).toUpperCase()}, white text passes`;
});
const ramp = () => { const [h, s] = rgb2hsv(rgb()), L = [97, 93, 85, 74, 62, 50, 41, 33, 25, 18];
return L.map(l => { const sat = Math.min(100, s * 100 * (l > 80 ? .9 : 1)), C = (1 - Math.abs(2 * l / 100 - 1)) * sat / 100, X = C * (1 - Math.abs((h / 60) % 2 - 1)), m = l / 100 - C / 2;
const [r1, g1, b1] = h < 60 ? [C, X, 0] : h < 120 ? [X, C, 0] : h < 180 ? [0, C, X] : h < 240 ? [0, X, C] : h < 300 ? [X, 0, C] : [C, 0, X];
return hex([r1, g1, b1].map(x => Math.round((x + m) * 255))); }); };
mk.onclick = () => c.run(async () => {
const R2 = ramp(), names = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900], here = hex(rgb());
// the step nearest the picked colour is its place in the scale
const near = R2.reduce((b, x, i) => Math.abs(lum(parse(x)) - lum(parse(here))) < Math.abs(lum(parse(R2[b])) - lum(parse(here))) ? i : b, 0);
sc.innerHTML = ''; mk.innerHTML = '<span class="spin"></span>Making a scale'; await c.wait(400);
for (let i = 0; i < 10; i++){ const b = html(`<button data-tip="${names[i]}: ${(i === near ? here : R2[i]).toUpperCase()}" aria-label="${names[i]}, ${(i === near ? here : R2[i]).toUpperCase()}"${i === near ? ' class="base"' : ''}><i style="background:${i === near ? here : R2[i]}"></i><span class="num">${names[i]}</span></button>`); sc.appendChild(b); enter(b); await c.wait(60); }
mk.innerHTML = `${I('layers','xs')}Make a scale`; sr.textContent = `Scale made, ${here.toUpperCase()} is step ${names[near]}`;
});
paint();
// the preview's story: a bright blue fails with white text, the agent darkens it until it passes, then makes a scale from it
if (r === c.st) c.story(async s => {
await s.beat(3);
await s.tap($('button', sw)); await s.beat(3);
await s.point(lines[0], false); await s.beat(2);
await s.tap(fix); await s.beat(4);
await s.tap(mk); s.away(); await s.beat(6);
});
});
Questions
What people ask about it.
- Which contrast rule does it check?
- WCAG 2's ratio, with 4.5:1 for body text. For large text 3:1 is enough, so a Fail here can still be fine for a big heading.
- Does Suggest an accessible shade call a model?
- No. It works it out in the browser: it keeps the hue and saturation and lowers the brightness step by step until white text passes.
- How do I hide the opacity rail?
- Leave out both
alphaandonAlphaChange.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.