Skip to content
Star on GitHub
Forms and inputsColour picker
Style
Accent
Sound

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 call

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. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.

$npx designme add color-picker
AaWhite text
AaInk text

01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add color-picker

React

$npx designme add color-picker --react

Astro

$npx designme add color-picker --astro

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

02

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

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" />
04

Props

Everything ColorPicker takes.

PropTypeWhat it does
valuestringA hex colour, like "#2f8cff"
defaultValuestring
onValueChange(hex: string) => void
alphanumberOpacity, 0 to 1. Leave both out to hide the opacity rail
onAlphaChange(alpha: number) => void
swatchesstring[]
inkstringThe dark text colour the contrast is checked against (#111111)
onScale(scale: { step: number; hex: string; base: boolean }[]) => voidCalled when a scale is made, with its ten steps
classNamestring
05

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);
  });
});
06

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 alpha and onAlphaChange.
07

More in Forms and inputs

Buttons, fields, pickers and sign-in flows for any product.

ButtonsEvery button style, with loading, success and hold-to-confirm states.
BadgeSmall labels for plans, environments and status, with a dot, an icon or a remove button.
Progress and meterA run's progress as a ring, steps and a bar, beside a credits meter that warns as it fills.
SliderNamed stops, a stepped scale and a range, with the agent's recommended value marked on the track.
TooltipQuiet labels on hover, with shortcuts and a richer preview card.
Search inputSearch with scoped filters, recent queries and an agent answer.
Country selectA searchable select with country codes, currency and keyboard support.
Multi-selectA combobox that filters as you type and keeps your picks as chips.
All 15 in Forms and inputs →