Skip to content

@absoluteui/helpers/color

Colour parsing and conversion (hex, rgb, hsl, oklch) and WCAG contrast checks.

import { ... } from '@absoluteui/helpers/color';

Parse any CSS colour, convert between hex, rgb(), hsl(), and oklch(), derive shades, and check WCAG contrast. Reach for it when colours come from users or data (department badges, calendar categories, brand settings) and text on them must stay readable. The Theming guide covers design tokens; this module works on single colour values.

How do I pick readable text for a colour a user chose?

Section titled “How do I pick readable text for a colour a user chose?”

bestTextColor returns black or white (or the best of your own candidates) for the background, and meetsContrast confirms the pair passes WCAG AA.

import { bestTextColor, meetsContrast } from '@absoluteui/helpers/color';
const badge = (background: string) => {
const text = bestTextColor(background, ['#111827', '#ffffff']);
return { background, color: text, readable: meetsContrast(text, background) };
};
badge('#1e3a8a'); // => { background: '#1e3a8a', color: '#ffffff', readable: true }
badge('#fde68a'); // => { background: '#fde68a', color: '#111827', readable: true }

How do I check a set of theme colours against WCAG?

Section titled “How do I check a set of theme colours against WCAG?”

contrastRatio gives the unrounded ratio; meetsContrast applies the threshold for the level and size you need: AA 4.5, AA large text 3, AAA 7, and 3 for non-text elements such as focus rings and input borders.

import { contrastRatio, meetsContrast } from '@absoluteui/helpers/color';
const pairs = [
{ name: 'body text', fg: '#374151', bg: '#ffffff', size: 'normal' },
{ name: 'placeholder', fg: '#9ca3af', bg: '#ffffff', size: 'normal' },
{ name: 'focus ring', fg: '#2563eb', bg: '#ffffff', size: 'non-text' },
] as const;
const failing = pairs.filter((p) => !meetsContrast(p.fg, p.bg, { size: p.size })).map((p) => p.name);
failing; // => ['placeholder']
Number(contrastRatio('#9ca3af', '#ffffff').toFixed(2)); // => 2.54

How do I derive hover, pressed, and tint shades from one brand colour?

Section titled “How do I derive hover, pressed, and tint shades from one brand colour?”

darken and lighten move OKLCH lightness, so the hue stays the same to the eye; withAlpha makes a translucent tint and mix blends two colours. Format the result for CSS with formatHex, formatRgb, or formatOklch.

import { contrastRatio, darken, formatHex, formatRgb, lighten, mix, withAlpha } from '@absoluteui/helpers/color';
const brand = '#2563eb';
const hover = formatHex(darken(brand, 0.08));
const pressed = formatHex(darken(brand, 0.16));
contrastRatio(pressed, '#fff') > contrastRatio(hover, '#fff'); // => true
contrastRatio(formatHex(lighten(brand, 0.3)), '#fff') < contrastRatio(brand, '#fff'); // => true
formatRgb(withAlpha(brand, 0.12)); // => 'rgb(37 99 235 / 0.12)'
formatHex(mix(brand, '#ffffff', 1)); // => '#ffffff'

How do I validate and normalise a colour field?

Section titled “How do I validate and normalise a colour field?”

parseColor accepts every CSS form (hex, rgb(), hsl(), oklch(), named colours, transparent) and returns undefined for anything else. Store one canonical form, such as lowercase hex.

import { formatHex, parseColor } from '@absoluteui/helpers/color';
const normalise = (input: string) => {
const rgb = parseColor(input.trim());
return rgb === undefined ? undefined : formatHex(rgb);
};
normalise(' RebeccaPurple '); // => '#663399'
normalise('hsl(217 91% 60%)'); // => '#3c83f6'
normalise('rgb(0 128 0 / 50%)'); // => '#00800080'
normalise('blueish'); // => undefined

currentcolor and unknown names are not colours here, because they have no value outside the page. Translucent colours change contrast: contrastRatio composites them over the background (and a translucent background over white), so check the colour as it is actually drawn.

import { contrastRatio, parseColor, parseColorOrThrow } from '@absoluteui/helpers/color';
parseColor('currentcolor'); // => undefined
parseColorOrThrow('brand-blue'); // => throws HLP_COLOR_INVALID
contrastRatio('rgb(0 0 0 / 0.4)', '#ffffff') < contrastRatio('#000000', '#ffffff'); // => true

bestTextColor

The candidate with the highest contrast against bg (first wins ties).

function bestTextColor(bg: ColorInput): '#000000' | '#ffffff';
function bestTextColor<T extends ColorInput>(bg: ColorInput, candidates: readonly T[]): T;

Example:

bestTextColor('#1e3a8a'); // => '#ffffff'
bestTextColor('#fde68a', ['#1f2937', '#f9fafb']); // => '#1f2937'

colorDistance

Perceptual distance ΔEOK (Euclidean in Oklab; ~ 0.02 is a just-noticeable difference). Alpha ignored.

function colorDistance(a: ColorInput, b: ColorInput): number;

Example:

colorDistance('#000', '#fff'); // => ≈ 1.00

compositeOver

Alpha-composites fg over bg (source-over).

function compositeOver(fg: ColorInput, bg: ColorInput): Rgb;

Example:

compositeOver('rgb(0 0 0 / 0.5)', '#ffffff'); // => { r: 127.5, g: 127.5, b: 127.5, alpha: 1 }

contrastRatio

WCAG contrast ratio (1-21), unrounded. A translucent background is composited over white and a translucent foreground over the background.

function contrastRatio(fg: ColorInput, bg: ColorInput): number;

Example:

contrastRatio('#000', '#fff'); // => 21
contrastRatio('#777', '#fff'); // => ≈ 4.48

cssColorNames

The CSS named colour keywords (lowercase).

function cssColorNames(): string[];

Example:

cssColorNames().length; // => 148

darken

Lowers OKLCH lightness by amount (0-1, absolute).

function darken(color: ColorInput, amount?: number, options?: GamutOptions): Rgb;

Example:

formatHex(darken('#93c5fd', 0.2));

formatHex

Lowercase hex (#rrggbb or #rrggbbaa).

function formatHex(color: ColorInput, options?: FormatHexOptions): string;

Example:

formatHex('rgb(255 0 0 / 0.5)'); // => '#ff000080'

formatHsl

CSS hsl() in modern syntax.

function formatHsl(color: ColorInput): string;

Example:

formatHsl('#ff0000'); // => 'hsl(0 100% 50%)'

formatOklch

CSS oklch() with lightness as a percentage.

function formatOklch(color: ColorInput): string;

Example:

formatOklch('#ff0000'); // => 'oklch(62.796% 0.25768 29.234)'

formatRgb

CSS rgb() in modern space syntax, or legacy rgb(r, g, b)/rgba(...) with { legacy: true }.

function formatRgb(color: ColorInput, options?: {
readonly legacy?: boolean;
}): string;

Example:

formatRgb('#ff000080'); // => 'rgb(255 0 0 / 0.502)'

hslToRgb

HSL to sRGB.

function hslToRgb(hsl: Hsl): Rgb;

Example:

hslToRgb({ h: 120, s: 100, l: 25, alpha: 1 }); // => { r: 0, g: 127.5, b: 0, alpha: 1 }

isDark

True when white text contrasts better than black (translucent colours are composited over white).

function isDark(color: ColorInput): boolean;

Example:

isDark('#1e3a8a'); // => true

isLight

Opposite of isDark.

function isLight(color: ColorInput): boolean;

Example:

isLight('#fef3c7'); // => true

lighten

Raises OKLCH lightness by amount (0-1, absolute).

function lighten(color: ColorInput, amount?: number, options?: GamutOptions): Rgb;

Example:

formatHex(lighten('#1e3a8a', 0.2));

meetsContrast

WCAG threshold check: AA 4.5 (large 3), AAA 7 (large 4.5), non-text 3. The ratio is not rounded.

function meetsContrast(fg: ColorInput, bg: ColorInput, options?: MeetsContrastOptions): boolean;

Example:

meetsContrast('#777', '#fff'); // => false // 4.48 < 4.5
meetsContrast('#777', '#fff', { size: 'large' }); // => true

mix

Mixes two colours; weight is the share of b (0 -> a, 1 -> b). Alpha is interpolated linearly.

function mix(a: ColorInput, b: ColorInput, weight?: number, options?: MixOptions): Rgb;

Example:

formatHex(mix('#000', '#fff', 0.5, { space: 'srgb' })); // => '#808080'

oklchToRgb

OKLCH to sRGB with gamut mapping.

function oklchToRgb(lch: Oklch, options?: GamutOptions): Rgb;

Example:

const red = oklchToRgb({ l: 0.628, c: 0.2577, h: 29.23, alpha: 1 });
red.r; // => ≈ 255
red.g; // => ≈ 0
red.b; // => ≈ 0

parseColor

Parses a CSS colour: hex (#rgb, #rgba, #rrggbb, #rrggbbaa), rgb()/rgba() (legacy commas and modern space syntax), hsl()/hsla(), oklch(), the 148 named colours, and transparent. Returns undefined when invalid (including currentcolor).

function parseColor(input: string): Rgb | undefined;

Example:

parseColor('rgb(255 0 0 / 50%)'); // => { r: 255, g: 0, b: 0, alpha: 0.5 }
parseColor('rebeccapurple'); // => { r: 102, g: 51, b: 153, alpha: 1 }

parseColorOrThrow

Like parseColor but throws HLP_COLOR_INVALID.

function parseColorOrThrow(input: string): Rgb;

Example:

parseColorOrThrow('#fff'); // => { r: 255, g: 255, b: 255, alpha: 1 }

relativeLuminance

WCAG 2.x relative luminance (0 black - 1 white). Alpha is ignored: composite first with compositeOver.

function relativeLuminance(color: ColorInput): number;

Example:

relativeLuminance('#ffffff'); // => 1

rgbToHsl

sRGB to HSL.

function rgbToHsl(rgb: Rgb): Hsl;

Example:

rgbToHsl({ r: 255, g: 0, b: 0, alpha: 1 }); // => { h: 0, s: 100, l: 50, alpha: 1 }

rgbToOklch

sRGB to OKLCH (hue 0 for greys).

function rgbToOklch(rgb: Rgb): Oklch;

Example:

const white = rgbToOklch({ r: 255, g: 255, b: 255, alpha: 1 });
white.l; // => ≈ 1.000
white.c; // => ≈ 0.000
white.h; // => 0

withAlpha

Returns the colour with alpha (0-1) replaced.

function withAlpha(color: ColorInput, alpha: number): Rgb;

Example:

formatHex(withAlpha('#ff0000', 0.5)); // => '#ff000080'

FormatHexOptions

interface FormatHexOptions {
readonly alpha?: 'auto' | 'always' | 'never';
}

GamutOptions

interface GamutOptions {
readonly gamut?: 'chroma' | 'clip';
}

Hsl

Hue 0-360 (0 for greys), saturation and lightness 0-100, alpha 0-1.

interface Hsl {
readonly h: number;
readonly s: number;
readonly l: number;
readonly alpha: number;
}

MeetsContrastOptions

interface MeetsContrastOptions {
readonly level?: 'AA' | 'AAA';
readonly size?: 'normal' | 'large' | 'non-text';
}

MixOptions

interface MixOptions extends GamutOptions {
readonly space?: 'oklch' | 'srgb';
}

Oklch

OKLCH: lightness 0-1, chroma >= 0 (~0-0.4 for sRGB), hue 0-360, alpha 0-1.

interface Oklch {
readonly l: number;
readonly c: number;
readonly h: number;
readonly alpha: number;
}

Rgb

sRGB colour: channels 0-255 (may be fractional), alpha 0-1.

interface Rgb {
readonly r: number;
readonly g: number;
readonly b: number;
readonly alpha: number;
}

ColorInput

A CSS colour string or an RGB object (alpha defaults to 1).

type ColorInput = string | {
readonly r: number;
readonly g: number;
readonly b: number;
readonly alpha?: number;
};