How every value is calculated
Every conversion on this site comes from one file, lib/color.ts, and every function in it has a tested inverse. The palette generator adds lib/palette-gen.ts and semi-transparent contrast adds lib/alpha.ts. This page states which formula each tool uses, which specification defines it, and where the answer is an approximation rather than a fact.
Jump to: how the palette generator works or semi-transparent text.
Hex and RGB
A six digit hex color is three bytes written in base 16, two digits per channel. Conversion in both directions is exact and lossless: there is no rounding step and no color space assumption involved, because hex and RGB are two notations for the same numbers. Three digit shorthand expands by doubling each digit, so #0af is #00aaff, which is what CSS Color Module Level 4 specifies.
RGB to HSL and HSV
Both use the standard cylindrical transformations of the RGB cube. Hue comes from which channel is the maximum and how far the other two sit from it; saturation and lightness or value come from the maximum and minimum channels. These are geometric rearrangements of sRGB numbers, not perceptual models. Two colors with the same HSL lightness can look very different in brightness, which is the reason OKLCH exists.
HueKit stores HSL and HSV rounded to two decimals. Converting back to RGB can therefore move a channel by at most one step out of 255. The round trip is verified for twenty sample colors in scripts/verify-color.mjs.
RGB to CMYK, and why it is approximate
HueKit uses the naive conversion: K = 1 - max(R', G', B'), then C = (1 - R' - K) / (1 - K) and the same for M and Y, where each channel has first been divided by 255.
This is a formula, not a color management step. It assumes no ICC profile, no ink limit, no paper white and no dot gain. A real press has all four. Treat the CMYK values on this site as a starting point for an on screen preview, and get the final values from the printer's profile. This is why the CMYK numbers here say approximate: not as a disclaimer, but because a device independent CMYK does not exist.
CIE XYZ, CIELAB and LCH
Conversion to XYZ starts by linearising sRGB with the piecewise transfer function from IEC 61966-2-1, with its 0.04045 breakpoint, then applies the standard sRGB to XYZ matrix for a D65 white point. CIELAB follows from XYZ with the usual cube root function and its epsilon of (6/29)³. LCH is CIELAB in cylindrical form: chroma is the distance from the neutral axis, hue is the angle.
Anchor values that any correct implementation must reproduce, and which this one does: white is L* 100 with a* and b* at zero, black is L* 0, and any grey has chroma zero.
OKLab and OKLCH
OKLab is Bjorn Ottosson's 2020 space, adopted by CSS Color Module Level 4 as oklab() and oklch(). HueKit uses the published matrices: linear sRGB to an LMS-like space, a cube root, then a second matrix to Lab. The inverse is the exact reverse.
The practical reason it is here: equal steps in OKLab lightness look like equal steps, which is not true of HSL. A ramp built in HSL has yellows that look far brighter than blues at the same lightness value. A ramp built in OKLCH does not.
Cross check: converting the Tailwind CSS 4.3.1 palette, which is published in OKLCH, through this implementation reproduces the hex values Tailwind itself ships, including blue-600 as #155DFC and slate-900 as #0F172B. That is an independent check on the matrices, not a claim by us about our own code.
Color difference: CIE76, and what it is not
Where this site says how far apart two colors are, it uses CIE76: the straight Euclidean distance in CIELAB. It is named explicitly on every page that shows it, because it is not CIEDE2000. CIE76 overstates differences in saturated blues and understates some others. It is used here for one job only, ranking which named color is nearest, where the ordering matters more than the exact figure.
A rule of thumb often quoted is that a CIE76 distance below roughly 2.3 is around the threshold at which people notice a difference side by side. That is a rough guide, not a threshold to design against.
WCAG contrast
Contrast uses the relative luminance formula exactly as the WCAG specification writes it, including its 0.03928 breakpoint, which differs very slightly from the 0.04045 in the sRGB specification. The two are kept separate in the code on purpose: one drives the accessibility thresholds, the other drives colorimetry. Merging them would change published pass and fail results by a hair for no reason.
The ratio is (L1 + 0.05) / (L2 + 0.05), lighter over darker, so the range runs from 1:1 to 21:1. The thresholds are set by success criteria, not by us: 1.4.3 Contrast (Minimum) requires 4.5:1 for normal text and 3:1 for large text, 1.4.6 Contrast (Enhanced) requires 7:1 and 4.5:1, and 1.4.11 Non-text Contrast requires 3:1 for interface components and meaningful graphics.
Ratios are shown rounded to two decimals, but every pass and fail on HueKit is decided on the unrounded ratio, as WCAG intends: the criteria name a minimum and say nothing about rounding. #0078D7 on white is 4.4989:1. It shows as 4.50:1 and still fails 4.5:1, and the contrast checker says so next to the verdicts when a pair lands in that gap.
WCAG 2.2 is a W3C Recommendation, most recently revised on 12 December 2024, and it carries all three criteria over from 2.1 with the luminance formula unchanged. A tool that says 2.1 and a tool that says 2.2 compute the same number. APCA, the perceptual contrast algorithm discussed for a future version, is a different calculation with different thresholds and is not used here.
Shades, tints and harmonies
Shades mix toward black and tints toward white, in linear steps in sRGB. That is the common definition and it is what most design tools do, but it is not perceptually even. On the converter pages, complementary, analogous and triadic pairings rotate hue by 180, 30 and 120 degrees in HSL. These are geometric conventions from the color wheel, useful as starting points rather than as rules. The palette generator works differently, in OKLCH, as described below.
Color names
Two different things are called a color name on this site and they are not equally solid. The 148 CSS keywords are normative: CSS Color Module Level 4 fixes both the names and their values, and HueKit uses them unaltered. The names in the color library are a curated list of common usage, which is convention rather than standard. Where a library name and a CSS keyword disagree, the keyword is the one to cite.
Palette generator
The palette generator builds every color in OKLCH, so equal steps in lightness look like equal steps. It is deterministic: each palette comes from a 32-bit seed run through the mulberry32 generator, and the same seed, harmony, size and locks always give the same colors. Generate, or Space, only draws a new seed. A palette holds 3 to 10 colors, 5 by default.
Harmony rules
Each harmony is a set of hue offsets from one base hue, measured on the OKLCH hue circle rather than the HSL wheel:
- Analogous: Evenly spaced from -h to +h around the base hue, where the seed sets h between 18 and 40 degrees, so the palette spans 36 to 80 degrees.
- Complementary: 0 and 180 degrees. Each slot moves by up to 8 degrees either way.
- Split complementary: 0, 150 and 210 degrees. Each slot moves by up to 8 degrees either way.
- Triad: 0, 120 and 240 degrees. Each slot moves by up to 8 degrees either way.
- Monochrome: 0 degrees for every slot, moved by up to 3 degrees either way.
- Random: Any hue in each slot, drawn from the seed.
Slots are shared out over the hues in order: slot i of n goes to hue group floor(i × groups / n), so a five color triad puts two, two and one colors on its three hues. The base hue comes from the seed, unless a locked color has an OKLCH chroma of 0.03 or more. The first such color anchors the palette: the base hue is set so that the offset of its own slot lands exactly on its hue, and the free slots are built around it.
Lightness and chroma
Lightness runs as a ramp on the OKLCH L axis, from 0 to 1. Analogous and monochrome palettes are one ramp in slot order; complementary, split complementary and triad palettes run dark to light inside each hue group; random palettes take a shuffled order. The seed decides whether the ramp climbs or falls. Normally its dark end lies between 0.30 and 0.44 and its light end between 0.82 and 0.94. In contrast-aware mode it is stretched: the dark end lies between 0.20 and 0.30 and the light end between 0.90 and 0.97. Each slot then moves by at most 0.02, and L stays between 0.05 and 0.98.
Chroma starts from a base between 0.07 and 0.18, or from the anchor color's own chroma held between 0.05 and 0.20. Each slot gets 0.7 to 1.3 times that base, and a monochrome slot 0.35 to 1.0 times, so a single hue still shows soft and saturated steps.
Staying inside sRGB
Many OKLCH colors have no sRGB equivalent. Clipping a channel would turn the hue, so the generator lowers chroma instead, to the largest value sRGB can show at that lightness and hue. Rounding to 8-bit hex can still turn the hue of a soft or very dark color by a few degrees, so the byte values around the exact result are compared: one step in each direction, two below L 0.3 and three below L 0.15. Among the candidates that keep the hue within 2.5 degrees, the one nearest to the hue line and to the exact color wins. At a chroma of 0.0195 or less a color counts as neutral, because its hue no longer shows.
The contrast-aware rule
Contrast-aware mode is on by default. It guarantees at least one pair at 4.5:1, the WCAG AA level for normal text, whenever at least one swatch is unlocked. After a palette is built every pair is measured, unrounded. If none reaches 4.5:1, two repairs are compared: push the darkest unlocked swatch darker against the lightest other swatch, or push the lightest unlocked swatch lighter against the darkest other swatch. Both move only along the OKLCH L axis, in steps of 0.001, with the hue kept. The repair that needs fewer steps wins; a tie goes to the darker one.
One of the two always works. Black reaches 4.5:1 against any color with a relative luminance of 0.175 or more, and white against any color of 0.1833 or less. Those ranges overlap, so pushing far enough one way or the other always lands on a passing pair. A unit test replays the rule across seeds, sizes, harmonies and locks.
Contrast per pair and Fix pair
The contrast panel lists every pair, n × (n - 1) / 2 of them, so ten for five colors, with the WCAG ratio shown rounded to two decimals. Each pair is graded on the unrounded ratio against 3:1 (large text and interface parts), 4.5:1 (AA for normal text) and 7:1 (AAA), the thresholds described under WCAG contrast above, so a pair shown as 4.50:1 can still miss AA and be offered a fix.
Fix pair changes one color of a failing pair by the smallest lightness step that reaches 4.5:1 against the other. For each unlocked color of the pair it searches both directions along the OKLCH L axis in steps of 0.001 with a binary search, so the result passes and one step less does not. The color that needs fewer steps is the one that changes. Hue is kept, and chroma only drops where the sRGB gamut requires it. When no lightness reaches the target, or both colors are locked, Fix pair says so instead of guessing.
Names, tokens and links
Every swatch is named after the nearest of the 254 library colors by CIE76 distance in CIELAB, the same measure described above; an exact match has distance 0. Export token names are that name as a lowercase slug, with -2, -3 and so on added when two swatches land on the same name.
A palette lives in the address bar as ?c= followed by 3 to 10 hex values without the #, separated by dashes, for example ?c=264653-2a9d8f-e9c46a. Locks travel as &lk= with one 0 or 1 per color, and ?lock=fada5e starts a new palette with that color locked in the first slot. The canonical address of the page never carries a query.
Semi-transparent text: alpha compositing
WCAG measures the colors that end up on screen. Text at 50% opacity is not a color of its own: the browser blends it with whatever sits behind it, and the contrast check has to run on that blend. The contrast checker blends first, then measures the result against the background with the WCAG formula above.
Each channel is fg × a + bg × (1 - a), where a is the alpha from 0 to 1. That is straight, not premultiplied, alpha over an opaque background in gamma-encoded sRGB, which is what CSS opacity and rgb(r g b / a) paint by default. The result is kept unrounded: 50% black on white is 127.5 per channel, not 127 or 128. At an alpha of 1 the result is exactly the text color and at 0 exactly the background, so an opaque pair measures the same as it always has.
Four and eight digit hex, #rgba and #rrggbbaaas CSS Color Module Level 4 defines them, carry alpha in the last digit pair: alpha is that byte divided by 255. Opacity is set in whole percent, and percent converts back to a byte as round(percent × 255 / 100), so 50% is 80 in hex, 128. The background is always treated as opaque, because a translucent background has no single color to measure against.
The lowest passing opacity is found by scanning down from 100% one percent at a time and stopping at the first failure, so the number shown passes at every opacity from there up to 100%, even for pairs whose contrast does not rise steadily. The suggested text color keeps the HSL hue and saturation and steps HSL lightness one point at a time away from the background: lighter when the background's contrast against black is below 10.5:1, a relative luminance below 0.475, and darker otherwise. Every candidate is measured after the blend. At 100% opacity the suggestion is exactly the opaque one.
What is not modelled
- No ICC color management. Everything assumes sRGB, which is right for the web and wrong for print and for wide gamut work.
- No color vision deficiency simulation. Those are models of a condition, not a view through someone's eyes, and publishing one without naming the model would be misleading.
- No APCA scores. Adding them next to WCAG numbers invites people to mix two incompatible scales.
Reproduce it
The conversions are in one file and the checks in another. Every value on the site, and every value in the JSON dataset, is produced by those functions at build time, so a number in the copy cannot drift away from a number in a table.
Related
Last updated by FusionStudios. Every value on this page is computed by the same functions the tools use. How this is calculated.