Color Format Converter & WCAG Contrast Checker (HEX · rgb · hsl)
Convert one color between HEX, rgb() and hsl() and read its WCAG relative luminance, then test a foreground/background pair against all five WCAG 2.1 contrast thresholds and get the smallest fix when it fails. Everything is computed in your browser.
Part 1 — one color: every notation plus its luminance. Accepted input: #f0a, #FF00AA, FF00AA, rgb(255, 0, 170).
| Notation / derived value | Result |
|---|---|
| HEX (uppercase) | — |
| rgb() | — |
| hsl() | — |
| Linearised R / G / B | — |
Worked colours
| You type | HEX (uppercase) | rgb() | Relative luminance L |
|---|---|---|---|
#f0a | #FF00AA | rgb(255, 0, 170) | 0.2416 |
rgb(119,119,119) | #777777 | rgb(119, 119, 119) | 0.1845 |
#FFFFFF | #FFFFFF | rgb(255, 255, 255) | 1.0000 |
hsl(210, 60%, 40%) | — | — | — |
Part 2 — foreground on background: the five WCAG 2.1 gates, one verdict each. The two parts are independent: a parse error here leaves Part 1 untouched.
| Requirement | Measured | Verdict |
|---|---|---|
| Body text AA · needs ≥ 4.5 | — | — |
| Body text AAA · needs ≥ 7 | — | — |
| Large text (≥ 18.66px bold or ≥ 24px) AA · needs ≥ 3 | — | — |
| Large text AAA · needs ≥ 4.5 | — | — |
| Non-text UI component / graphic · needs ≥ 3 | — | — |
Verdicts use the unrounded ratio, so occasionally a row reads "4.50:1 — Fail": that is 4.497x being rounded up for display only.
The maths behind the numbers
Two jobs, deliberately kept apart
Part 1 answers “what else can I call this color”. A design file says #FF00AA, a CSS custom property wants rgb(), a theme generator works in hsl(), and an accessibility audit wants a luminance number - four spellings of one thing. The parser is forgiving about the useful variations (# optional, three-digit shorthand, pasted rgb(...) with commas or spaces) and strict about everything else, with an error that quotes the offending token: #1a2g89 reports position 4 (whitespace is dropped first, then positions count the digits after the #, or the items inside rgb()).
Part 2 answers a different question, so it gets its own inputs and its own message line. A broken background color there cannot wipe a valid conversion above, and vice versa.
The maths behind the numbers
Luminance. Each sRGB channel is normalised (c = v / 255) and linearised, removing the gamma encoding screens apply:
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ^ 2.4
L = 0.2126 R + 0.7152 G + 0.0722 B
The weights are why green dominates: the eye is roughly twelve times more sensitive to it than to blue. #00FF00 reaches L = 0.7152, while #0000FF only gets 0.0722.
Ratio. ratio = (max(L1, L2) + 0.05) / (min(L1, L2) + 0.05), displayed to two decimals. The 0.05 models the light that even “black” reflects in a room; it is what caps the scale at 21:1 (#000000 on #FFFFFF) instead of infinity.
Thresholds, from WCAG 2.1. Success criterion 1.4.3 (AA) needs 4.5 for body text and 3 for large text, where “large” means at least 24px, or at least 18.66px at weight 700 or above. 1.4.6 (AAA) needs 7 and 4.5. 1.4.11 (non-text contrast) needs 3 for UI component edges and meaningful graphics. Every row in the table shows the same measured ratio against its own bar.
HSL. Standard conversion; hue is rounded to a whole degree, S and L to one decimal. A neutral grey has no hue by definition, so it is reported as hsl(0, 0.0%, l%).
The repair suggestion. When the ratio falls short of 4.5, one side is scaled toward pure black or pure white, channel by channel and by the same fraction (v ← v + (target − v) × i/255), one 255th per step, and whichever of the four directions passes in the fewest steps is reported. Proportional scaling is what keeps hue and saturation intact - subtracting a fixed amount would quietly desaturate. For #777777 on white, two steps give #767676, the gray that finally clears 4.5:1 at 4.54:1.
Where this can mislead you
- It assumes your screen is sRGB and uncalibrated-but-correct. A wide-gamut phone, a “warm” night-light profile, a color-managed browser, or a monitor with a custom ICC curve means the pixels you are looking at are not
119/255. The arithmetic is exact; the color in front of you may not be. - Contrast is a luminance ratio, not legibility and not taste. Font weight, x-height, anti-aliasing, letter-spacing, texture behind the text, and how tired the reader is all affect readability and none of them are in the formula. AA is a floor negotiated in 1990s-ish vision research terms, not a comfort setting.
- Hue confusion is invisible to it. Red carries weight 0.2126, so
#FF0000on white scores 4.00:1 while a same-lightness green sails past 7:1 - and#FF0000on black is 5.25:1. If color is the only thing distinguishing states, add an icon or a label. - Transparency is refused on purpose.
rgba(), 4- and 8-digit hex are rejected because an alpha-bearing color has no single contrast value: composite it onto its real background first, then paste the result. - Rounded display, unrounded verdict. 4.497 shows as
4.50:1and still reads Fail. Near a boundary, treat it as failing.
Questions
Why won’t it accept red or hsl(120, 50%, 50%) as input? Because “which red” is a spec-version question (CSS4 red is #FF0000, but rebeccapurple and color-mix() open a can of parsing), and pointing at one character beats guessing. Write hex or rgb(); hsl() is produced for you.
Which gray is safe on white? Anything at #767676 or darker. #777777 - a number people paste from old stylesheets constantly - is 4.48:1 and fails body AA by a hair.
Does what I type go anywhere? It stays in the tab: parsing, linearisation and the ratio are all in the script, and none of them ships the colors you enter off to a server, so they never leave this device.