CSS Module 6 – Colour and theming
Colour syntax: hex, rgb(), hsl() and keywords
Write CSS colours in hex, rgb(), hsl() and hwb() with alpha, use currentColor and transparent, and check text contrast against WCAG 2.2.
What you will learn
- Write colours in hex, rgb(), hsl() and hwb() using the space-separated syntax with alpha
- Predict what getComputedStyle() reports for a colour, and which colour values a browser drops
- Use currentColor and transparent so borders and icons follow the text colour
- Check text and interface contrast against the WCAG 2.2 thresholds
Before you start
What you’ll learn · Before you start
What you will learn
- Write colours in hex, rgb(), hsl() and hwb() using the space-separated syntax with alpha
- Predict what getComputedStyle() reports for a colour, and which colour values a browser drops
- Use currentColor and transparent so borders and icons follow the text colour
- Check text and interface contrast against the WCAG 2.2 thresholds
Before you start
On this page
A CSS colour is three channels, red, green and blue, plus an alpha channel that says how see-through it is. You can
write the same colour in several notations: a hex code such as #0044cc, the rgb() function with the three
channels as numbers, hsl() with a hue, a saturation and a lightness, hwb() with a hue and amounts of white
and black, or a keyword such as rebeccapurple. All of these describe colours in sRGB, the colour space of
ordinary screens, so the browser turns each one into the same three numbers. An alpha after a slash makes a colour
translucent: rgb(0 68 204 / 50%). Two keywords behave differently: transparent, and currentColor, which borrows
the element’s text colour. Whichever notation you choose, text has to stand out from its background, and WCAG 2.2
puts a number on that: a contrast ratio of at least 4.5:1 for body text.
This lesson writes one blue in six notations and reads back what Chromium 153, Firefox 155 and WebKit 26.6 computed, shows why HSL’s lightness can’t be trusted, and checks the colours of a product card against WCAG 2.2.
One blue, six notations
The page below paints fourteen swatches. The first six use six notations for one opaque blue, the next three the same
blue at half opacity, two contain mistakes, and the last three test values outside the range, a keyword and an hsl()
that doesn’t land on whole numbers. The script at the end prints what the browser computed:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>One blue, written six ways, and a few values to compare</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
.swatch {
inline-size: 14rem;
padding: 8px 12px;
margin-block-end: 8px;
color: white;
background-color: gray;
}
.see-through { color: black; }
/* Six notations for one opaque blue */
#hex6 { background-color: #0044cc; }
#hex3 { background-color: #04c; }
#rgb { background-color: rgb(0 68 204); }
#percent { background-color: rgb(0% 26.667% 80%); }
#hsl { background-color: hsl(220 100% 40%); }
#hwb { background-color: hwb(220 0% 20%); }
/* The same blue at half opacity, three ways */
#hex8 { background-color: #0044cc80; }
#slash { background-color: rgb(0 68 204 / 50%); }
#legacy { background-color: rgba(0, 68, 204, 0.5); }
/* Two invalid values: each declaration is dropped, so the gray from .swatch stays */
#mixed { background-color: rgb(0, 68 204); }
#five { background-color: #0044c; }
/* Three more: values out of range, a keyword, and an hsl() that is not a whole rgb() */
#clamped { background-color: rgb(300 -20 204); }
#keyword { background-color: rebeccapurple; }
#rounded { background-color: hsl(221 83% 53%); }
</style>
</head>
<body>
<div class="swatch" id="hex6">#0044cc</div>
<div class="swatch" id="hex3">#04c</div>
<div class="swatch" id="rgb">rgb(0 68 204)</div>
<div class="swatch" id="percent">rgb(0% 26.667% 80%)</div>
<div class="swatch" id="hsl">hsl(220 100% 40%)</div>
<div class="swatch" id="hwb">hwb(220 0% 20%)</div>
<div class="swatch see-through" id="hex8">#0044cc80</div>
<div class="swatch see-through" id="slash">rgb(0 68 204 / 50%)</div>
<div class="swatch see-through" id="legacy">rgba(0, 68, 204, 0.5)</div>
<div class="swatch" id="mixed">rgb(0, 68 204)</div>
<div class="swatch" id="five">#0044c</div>
<div class="swatch" id="clamped">rgb(300 -20 204)</div>
<div class="swatch" id="keyword">rebeccapurple</div>
<div class="swatch" id="rounded">hsl(221 83% 53%)</div>
<script>
// What the browser computed for each swatch, in the form getComputedStyle() reports.
for (const el of document.querySelectorAll('.swatch')) {
console.log(`${el.id.padEnd(8)} ${getComputedStyle(el).backgroundColor}`);
}
</script>
</body>
</html> All three engines printed the same values:
| Written as | Computed |
|---|---|
#0044cc |
rgb(0, 68, 204) |
#04c |
rgb(0, 68, 204) |
rgb(0 68 204) |
rgb(0, 68, 204) |
rgb(0% 26.667% 80%) |
rgb(0, 68, 204) |
hsl(220 100% 40%) |
rgb(0, 68, 204) |
hwb(220 0% 20%) |
rgb(0, 68, 204) |
#0044cc80, rgb(0 68 204 / 50%), rgba(0, 68, 204, 0.5) |
rgba(0, 68, 204, 0.5) |
rgb(0, 68 204) |
rgb(128, 128, 128), the gray of .swatch |
#0044c |
rgb(128, 128, 128), the gray of .swatch |
rgb(300 -20 204) |
rgb(255, 0, 204) |
rebeccapurple |
rgb(102, 51, 153) |
hsl(221 83% 53%) |
rgb(36, 99, 235) |
Each notation has its own logic:
- Hex codes are written with
#and 3, 4, 6 or 8 digits from0tof, in either case. Six digits are three pairs, one per channel, each a number from 0 to 255 in base 16. Eight digits add a fourth pair for the alpha. The short forms double every digit, so#04cmeans#0044cc, which is why the three-digit form can only express 4,096 colours against about 17 million for six digits. rgb()takes the three channels as numbers from 0 to 255 or as percentages, where 100% means 255. Values outside the range are not errors: they are clamped, which is why theclampedswatch came out asrgb(255, 0, 204).hsl()starts from a hue, an angle on the colour wheel (0 is red, 120 green, 240 blue), then a saturation from 0% (gray) to 100% (the pure hue) and a lightness from 0% (black) to 100% (white), with the pure hue at 50%.hwb()also starts from a hue, then says how much white and how much black to mix in, which many people find the most natural way to make tints and shades:hwb(220 0% 20%)is hue 220 with no white and 20% black.- Named colours are 148 keywords, from
alicebluetoyellowgreen, case-insensitive, all in sRGB. CSS Color 4 keeps them for compatibility but does not encourage them: the names are not even consistent, anddarkgrayis lighter thangray.
Reading a hex colour two digits at a time
Text description of the diagram
The diagram reads the eight-digit hex colour #0044cc80 two digits at a time, the four pairs shown in a grid of two by two. Each pair is a number from 0 to 255 written in base 16, where a to f stand for 10 to 15.
- The first pair, 00, is the red channel: 0.
- The second pair, 44, is the green channel: 4 × 16 + 4 = 68.
- The third pair, cc, is the blue channel: 12 × 16 + 12 = 204.
- The fourth pair, 80, is the alpha channel: 8 × 16 = 128, and 128 ÷ 255 is about 0.5, so the colour is half transparent.
The four values together are the same colour as rgb(0 68 204 / 50%). A six-digit code has no alpha pair and is fully opaque, and the three- and four-digit forms double each digit, so #04c8 means #0044cc88.
What getComputedStyle() reports
Whatever you wrote, a script, the Computed pane of DevTools and getComputedStyle() show an sRGB colour as rgb()
or rgba() with commas. CSS Color 4 chose that serialization for compatibility with scripts written for older
browsers, so a test that compares colours should compare those strings, or parse them, never the text in your
stylesheet. A named colour is reported the same way: rebeccapurple came back as rgb(102, 51, 153).
Conversions between notations are not always exact. hsl(221 83% 53%) works out to red 35.7, green 98.7 and blue
234.6, and all three engines reported it as rgb(36, 99, 235). Rounding a colour to whole HSL numbers moves it
slightly, which matters when a design system must match its brand colour exactly; keep the source value, or convert
with a tool that keeps the decimals.
Alpha, and the modern and legacy syntaxes
Every colour function now has a modern syntax: the channels are separated by spaces, and an optional alpha comes
after a slash, as a number from 0 to 1 or a percentage. The older legacy syntax separates everything with commas
and puts the alpha in a fourth comma-separated slot, which is why it needed separate functions, rgba() and
hsla(). Today rgba() and hsla() are simply other names for rgb() and hsl(): rgba(0 68 204 / 0.5) and
rgb(0, 68, 204, 0.5) are both valid.
Pick one syntax per value. A function that mixes commas and spaces, such as rgb(0, 68 204), is invalid, and an
invalid value makes the browser drop the whole declaration, exactly as it does for any other mistake: in the run above
the swatch kept the gray from .swatch. A five-digit hex code is dropped the same way. The modern syntax also allows
two things the legacy one never did: the keyword none for a missing channel, which matters when colours are mixed,
and numbers and percentages mixed in one function, such as rgb(0% 68 80%). The space-separated syntax and the alpha
after a slash are Baseline widely available; mixing numbers and percentages is newer, Baseline newly available, so
don’t rely on it for visitors on older browsers.
Alpha belongs to one colour. color: rgb(0 0 0 / 60%) makes only the text translucent, while opacity: 0.6 fades
the whole element, its background, its borders and its children included.
Why HSL lightness can’t be trusted
HSL looks perceptual: same lightness, same brightness. It isn’t. The program below takes eight hues at the same
hsl() saturation and lightness and measures how light each one really is, using the relative luminance and contrast
ratio that WCAG 2.2 defines:
// Eight colours that hsl() calls equally light (saturation 100%, lightness 50%), and how light they really are.
// hsl() becomes sRGB with the formula of CSS Color 4; luminance and contrast follow the definitions of WCAG 2.2.
/** hsl(h s% l%) as sRGB channels from 0 to 1. */
function hslToRgb(h, s, l) {
s /= 100;
l /= 100;
const channel = (n) => {
const k = (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
};
return [channel(0), channel(8), channel(4)];
}
/** WCAG relative luminance: 0 for black, 1 for white. */
function luminance([r, g, b]) {
const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
}
/** WCAG contrast ratio of two colours, from 1 to 21. */
function contrast(a, b) {
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (light + 0.05) / (dark + 0.05);
}
const toHex = (rgb) => '#' + rgb.map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('');
const white = [1, 1, 1];
const black = [0, 0, 0];
const header = ['hsl()'.padEnd(20), 'hex'.padEnd(7), 'luminance', 'on white', 'on black'];
console.log(header.join(' '));
for (const hue of [0, 30, 60, 120, 180, 210, 240, 300]) {
const rgb = hslToRgb(hue, 100, 50);
const cells = [
`hsl(${hue} 100% 50%)`.padEnd(20),
toHex(rgb),
luminance(rgb).toFixed(3).padStart(9),
contrast(rgb, white).toFixed(2).padStart(8),
contrast(rgb, black).toFixed(2).padStart(8),
];
console.log(cells.join(' '));
} Output
hsl() hex luminance on white on black hsl(0 100% 50%) #ff0000 0.213 4.00 5.25 hsl(30 100% 50%) #ff8000 0.366 2.53 8.31 hsl(60 100% 50%) #ffff00 0.928 1.07 19.56 hsl(120 100% 50%) #00ff00 0.715 1.37 15.30 hsl(180 100% 50%) #00ffff 0.787 1.25 16.75 hsl(210 100% 50%) #0080ff 0.225 3.81 5.51 hsl(240 100% 50%) #0000ff 0.072 8.59 2.44 hsl(300 100% 50%) #ff00ff 0.285 3.14 6.70
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node hsl_lightness.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Every row has a lightness of 50%, yet the relative luminance runs from 0.072 for blue to 0.928 for yellow, a factor of almost 13. White text on the blue has a contrast of 8.59:1, comfortable to read; on the yellow it has 1.07:1, close to invisible. HSL’s lightness is just the average of the largest and the smallest channel, while the eye is far more sensitive to green than to blue: in the luminance formula green counts for 0.7152 and blue for only 0.0722.
This is what breaks palettes built in HSL. “Lighten by 10%” gives different steps for different hues, so a set of buttons or a chart that looks balanced in blue looks washed out in yellow. The next lesson replaces HSL with OKLCH, whose lightness was designed to match what people see.
currentColor and transparent
currentColor stands for the value of the color property on the same element. Borders and outlines already default
to it: border: 2px solid with no colour draws the border in the text colour, because the initial value of
border-color is currentcolor. Writing currentColor yourself is how you make other things follow the text, most usefully the icons of
inline SVG through fill: currentColor:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Buttons whose parts follow the text colour</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 16px; }
button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font: inherit;
border-radius: 6px;
}
/* 1. Every part follows color */
.icon-button {
color: #0044cc;
background-color: transparent;
border: 2px solid currentColor;
}
.icon-button svg { fill: currentColor; }
.icon-button:hover { color: #002e8a; } /* one declaration changes the text, the border and the icon */
/* 2. The same button with a fixed icon colour */
.fixed-icon { color: #0044cc; background-color: transparent; border: 2px solid currentColor; }
.fixed-icon svg { fill: #0044cc; }
/* 3. A filled button with a border that takes space but shows nothing */
.solid { color: white; background-color: #0044cc; border: 2px solid transparent; }
</style>
</head>
<body>
<button class="icon-button" type="button">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M5 3h11l3 3v15H5zM8 4v5h7V4zM8 13v6h8v-6z"/></svg>
Save
</button>
<button class="fixed-icon" type="button">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="M5 3h11l3 3v15H5zM8 4v5h7V4zM8 13v6h8v-6z"/></svg>
Save
</button>
<button class="solid" type="button">Buy</button>
<script>
const report = (when) => {
for (const button of document.querySelectorAll('button')) {
const s = getComputedStyle(button);
const path = button.querySelector('path');
const icon = path ? `, icon ${getComputedStyle(path).fill}` : '';
console.log(`${when} ${button.className.padEnd(11)} color ${s.color}, border ${s.borderTopColor}${icon}`);
}
};
report('at rest');
// A hover is reported one frame after the pointer arrives, once the styles have updated.
document.querySelector('.icon-button').addEventListener('mouseenter', () => requestAnimationFrame(() => report('hover ')));
</script>
</body>
</html> At rest, the first button’s text, border and icon all computed to rgb(0, 68, 204) in all three engines; while the
pointer was over it, all three computed to rgb(0, 46, 138). One declaration in the hover rule changed three parts,
and a dark theme or a disabled state needs only one new color too.
The habit pays off in forced colours mode, where the system replaces the page’s colours with the user’s own palette.
Under Chromium’s and Firefox’s emulation of that mode, both outlined buttons had their text forced to black. The icon
drawn with currentColor turned black with it, while the second button’s icon, with its fixed fill: #0044cc, stayed
blue: inline SVG keeps its own colours in that mode by design. With a dark palette, Chromium painted that blue icon on
black, a contrast of 2.70:1.
transparent is rgb(0 0 0 / 0), a fully transparent black. It is useful wherever something must take space without
showing: the third button’s border: 2px solid transparent keeps it the same size as the outlined ones. In forced
colours mode its blue background was replaced, and the browser gave the transparent border a visible system colour
(black in Chromium, a gray in Firefox), so the button kept its outline.
System colours
A last family of keywords names the colours of the browser or the operating system: Canvas and CanvasText for
the page background and its text, LinkText, ButtonFace, ButtonText, Field, FieldText, Highlight,
HighlightText, GrayText and a few more. They change with the colour scheme, and in forced colours mode they are the
only colours there are, which makes them the right choice for the few rules you write for that mode. Most are
Baseline widely available, but ButtonBorder, Mark and MarkText are not in Safari, and AccentColor is not in
Chrome on Android. The dark mode lesson of this module measures them.
Contrast: the numbers WCAG 2.2 asks for
WCAG 2.2 compares two colours by their relative luminance, how much light each sends to the eye on a scale from 0
for black to 1 for white. Each channel is converted from its gamma-encoded value to linear light, and the three are
weighted as 0.2126 red, 0.7152 green and 0.0722 blue. The contrast ratio is (L1 + 0.05) / (L2 + 0.05), where L1
is the luminance of the lighter colour, so it runs from 1:1 for two identical colours to 21:1 for black on white.
These success criteria of level AA use it:
- 1.4.3 Contrast (Minimum): text needs at least 4.5:1 against its background. Large text needs 3:1; WCAG defines it as at least 18 point, or 14 point bold, which its Understanding document puts at about 24 px and 18.5 px. Logos and the text of disabled controls are exempt.
- 1.4.11 Non-text Contrast: the parts that identify a control or its state, such as an input’s border, a checkbox’s tick or a focus indicator, and the parts of graphics needed to understand them need 3:1 against the colours next to them.
- 1.4.1 Use of Color (level A) adds that colour must never be the only way information is shown. A link inside a paragraph needs an underline, or a contrast of at least 3:1 with the text around it plus a cue that is not colour, such as an underline, on hover and focus.
The thresholds are not rounded: a ratio of 4.499:1 fails 4.5:1. The program below checks the eight colour pairs of one product card, painting a translucent colour over its background first, because that is what the reader sees:
// The colour pairs of one product card, checked against WCAG 2.2: 4.5:1 for text (1.4.3), 3:1 for large text
// (1.4.3) and for the parts of controls (1.4.11). Ratios are compared unrounded: 4.499 fails a 4.5 target.
/** "#rrggbb" or "#rrggbbaa" as channels from 0 to 1, plus alpha. */
function parseHex(hex) {
const digits = hex.slice(1);
const channel = (i) => parseInt(digits.slice(i, i + 2), 16) / 255;
return { rgb: [channel(0), channel(2), channel(4)], alpha: digits.length === 8 ? channel(6) : 1 };
}
/** A translucent colour painted over an opaque background: what the screen shows. */
function composite(top, background) {
return top.rgb.map((c, i) => c * top.alpha + background[i] * (1 - top.alpha));
}
function luminance([r, g, b]) {
const linear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
}
function contrast(a, b) {
const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (light + 0.05) / (dark + 0.05);
}
const pairs = [
{ what: 'body text', fg: '#1a1a1a', bg: '#ffffff', target: 4.5 },
{ what: 'caption', fg: '#8a8a8a', bg: '#ffffff', target: 4.5 },
{ what: 'link', fg: '#0044cc', bg: '#ffffff', target: 4.5 },
{ what: 'link vs body text', fg: '#0044cc', bg: '#1a1a1a', target: 3 },
{ what: 'button label', fg: '#ffffff', bg: '#f26b1d', target: 4.5 },
{ what: 'button label, dark', fg: '#1a1a1a', bg: '#f26b1d', target: 4.5 },
{ what: 'input border', fg: '#c4c4c4', bg: '#ffffff', target: 3 },
{ what: 'placeholder', fg: '#00000066', bg: '#ffffff', target: 4.5 },
];
const fixes = [
{ what: 'caption, darker', fg: '#767676', bg: '#ffffff', target: 4.5 },
{ what: 'caption, one step', fg: '#777777', bg: '#ffffff', target: 4.5 },
{ what: 'input border', fg: '#8a8a8a', bg: '#ffffff', target: 3 },
{ what: 'placeholder, 54%', fg: '#0000008a', bg: '#ffffff', target: 4.5 },
];
function report(title, rows) {
console.log(title);
console.log('pair colours ratio target result');
for (const p of rows) {
const bg = parseHex(p.bg).rgb;
const shown = composite(parseHex(p.fg), bg);
const ratio = contrast(shown, bg);
const cells = [
p.what.padEnd(19),
`${p.fg} on ${p.bg}`.padEnd(21),
ratio.toFixed(2).padStart(5),
String(p.target).padStart(6),
ratio >= p.target ? 'pass' : 'fail',
];
console.log(cells.join(' '));
}
}
report('The card as designed', pairs);
report('Candidate fixes', fixes); Output
The card as designed pair colours ratio target result body text #1a1a1a on #ffffff 17.40 4.5 pass caption #8a8a8a on #ffffff 3.45 4.5 fail link #0044cc on #ffffff 7.78 4.5 pass link vs body text #0044cc on #1a1a1a 2.24 3 fail button label #ffffff on #f26b1d 3.05 4.5 fail button label, dark #1a1a1a on #f26b1d 5.72 4.5 pass input border #c4c4c4 on #ffffff 1.74 3 fail placeholder #00000066 on #ffffff 2.85 4.5 fail Candidate fixes pair colours ratio target result caption, darker #767676 on #ffffff 4.54 4.5 pass caption, one step #777777 on #ffffff 4.48 4.5 fail input border #8a8a8a on #ffffff 3.45 3 pass placeholder, 54% #0000008a on #ffffff 4.61 4.5 pass
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node contrast_table.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Five of the eight fail. The second table tries a fix for three of them, and each failure has a usual answer:
- The caption,
#8a8a8aon white, reaches only 3.45:1. A slightly darker gray such as#767676reaches 4.54:1, and#777777, one step lighter, reaches only 4.48:1, which fails. - The link passes against the white page, but only reaches 2.24:1 against the body text, so readers who can’t see the hue can’t find it. Keep the underline.
- The orange button with a white label reaches 3.05:1: enough for a 24 px label, not for a 16 px one. Use dark text on it (5.72:1) or a darker orange.
- The input border
#c4c4c4reaches 1.74:1, well below the 3:1 a control’s boundary needs.#8a8a8a, the gray that failed as caption text, passes as a border at 3.45:1, because the target is lower. - The placeholder, black at 40% alpha, is painted as
#999999on the white field and reaches 2.85:1. Placeholder text is text, so it needs 4.5:1, which black at 54% alpha,#0000008a, reaches at 4.61:1.
Practise: read colours, then check their contrast
A contrast checker has to read colours before it can compare them. Write the parser and the ratio yourself, then run the sample tests:
Exercise · Medium · JavaScript
Read hex and rgb() colours, then check their contrast
A contrast checker has to read colours the way a browser does before it can do any maths. Write two functions in colours.mjs and export them.
The first, parseColour(text), returns { r, g, b, alpha }: the red, green and blue channels as numbers from 0 to 255 (not rounded) and the alpha from 0 to 1. It accepts:
- hex colours with 3, 4, 6 or 8 digits, in either case: #04c, #04c8, #0044cc, #0044CC80; - rgb() and rgba() in the modern syntax, with spaces between the channels and an optional / alpha: rgb(0 68 204), rgb(0% 26.667% 80%), rgb(0 68 204 / 50%), rgba(0 68 204 / 0.5); - rgb() and rgba() in the legacy syntax, with commas: rgb(0, 68, 204), rgba(0, 68, 204, 0.5); - the keyword transparent.
A channel written as a percentage is that share of 255, and an alpha written as a percentage is that share of 1. Values outside the ranges are clamped, as browsers do: rgb(300 -20 204) is { r: 255, g: 0, b: 204, alpha: 1 }. For anything else, such as five hex digits or commas mixed with spaces (rgb(0, 68 204)), throw a SyntaxError.
The second, contrastRatio(foreground, background), takes two opaque colours as text, reads them with parseColour and returns their WCAG 2.2 contrast ratio as an unrounded number: (L1 + 0.05) / (L2 + 0.05), where L1 is the relative luminance of the lighter colour. The lesson's examples show the luminance formula.
The sample tests import both functions from colours.mjs and run in your browser.
Starter code · colours.mjs
/** A CSS colour (hex, rgb(), rgba() or transparent) as { r, g, b, alpha }: channels 0-255, alpha 0-1. */
export function parseColour(text) {
// Replace this line with your code.
return { r: 0, g: 0, b: 0, alpha: 1 };
}
/** The WCAG 2.2 contrast ratio of two opaque colours, from 1 to 21, unrounded. */
export function contrastRatio(foreground, background) {
// Replace this line with your code.
return 1;
} The sample tests · colours.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { parseColour, contrastRatio } from './colours.mjs';
/** Compares a parsed colour with the expected one, allowing for rounding in percentages and alpha. */
function near(actual, expected, label) {
for (const key of ['r', 'g', 'b', 'alpha']) {
assert.ok(Math.abs(actual[key] - expected[key]) < 0.01, `${label}: ${key} is ${actual[key]}, expected ${expected[key]}`);
}
}
const blue = { r: 0, g: 68, b: 204, alpha: 1 };
test('six-digit hex, in either case', () => {
near(parseColour('#0044cc'), blue, '#0044cc');
near(parseColour('#0044CC'), blue, '#0044CC');
near(parseColour('#ffffff'), { r: 255, g: 255, b: 255, alpha: 1 }, '#ffffff');
});
test('three, four and eight digits', () => {
near(parseColour('#04c'), blue, '#04c');
near(parseColour('#04c8'), { ...blue, alpha: 0x88 / 255 }, '#04c8');
near(parseColour('#0044cc80'), { ...blue, alpha: 128 / 255 }, '#0044cc80');
});
test('the modern rgb() syntax, with numbers, percentages and a slash', () => {
near(parseColour('rgb(0 68 204)'), blue, 'rgb(0 68 204)');
near(parseColour('rgb(0% 26.667% 80%)'), blue, 'rgb(0% 26.667% 80%)');
near(parseColour('rgb(0 68 204 / 50%)'), { ...blue, alpha: 0.5 }, 'rgb(0 68 204 / 50%)');
near(parseColour('rgba(0 68 204 / 0.25)'), { ...blue, alpha: 0.25 }, 'rgba(0 68 204 / 0.25)');
});
test('the legacy syntax with commas', () => {
near(parseColour('rgb(0, 68, 204)'), blue, 'rgb(0, 68, 204)');
near(parseColour('rgba(0, 68, 204, 0.5)'), { ...blue, alpha: 0.5 }, 'rgba(0, 68, 204, 0.5)');
});
test('transparent, and values outside the ranges', () => {
near(parseColour('transparent'), { r: 0, g: 0, b: 0, alpha: 0 }, 'transparent');
near(parseColour('rgb(300 -20 204)'), { r: 255, g: 0, b: 204, alpha: 1 }, 'rgb(300 -20 204)');
});
test('invalid colours throw a SyntaxError', () => {
assert.throws(() => parseColour('#0044c'), SyntaxError);
assert.throws(() => parseColour('rgb(0, 68 204)'), SyntaxError);
assert.throws(() => parseColour('rgb(0 68)'), SyntaxError);
});
test('contrast ratios of known pairs', () => {
assert.equal(contrastRatio('#000000', '#ffffff'), 21);
assert.equal(contrastRatio('#0044cc', '#0044cc'), 1);
assert.equal(contrastRatio('#767676', '#ffffff').toFixed(3), '4.542');
assert.equal(contrastRatio('rgb(0 68 204)', '#fff').toFixed(2), '7.78');
});
test('the order of the two colours does not matter, and nothing is rounded', () => {
assert.equal(contrastRatio('#ffffff', '#767676'), contrastRatio('#767676', '#ffffff'));
assert.ok(contrastRatio('#777777', '#ffffff') < 4.5, '#777777 on white is 4.478:1, just under 4.5:1');
assert.ok(contrastRatio('#757575', '#ffffff') > contrastRatio('#767676', '#ffffff'), 'a darker grey has more contrast');
}); A hint
For hex, drop the #, double every digit when there are 3 or 4 of them, then read the pairs with parseInt(pair, 16); a fourth pair is the alpha, divided by 255. For rgb(), take the text between the parentheses: if it holds a comma, the parts are separated by commas; otherwise split it at / into the channels and the alpha, and split the channels on spaces. Either way there must be exactly three channels.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the difference between rgb(0 68 204 / 50%) and rgba(0, 68, 204, 0.5)?
None in the result: both are the same blue at half opacity, and getComputedStyle() reports both as
rgba(0, 68, 204, 0.5). The difference is syntax. The first uses the modern syntax, with spaces between the channels
and the alpha after a slash, as a number or a percentage. The second uses the legacy syntax, with commas and the alpha
as a fourth value, which is why rgba() once had to be a separate function. Today rgba() is just another name for
rgb(), so rgba(0 68 204 / 50%) works too. The modern syntax also allows none for a missing channel, and mixing
commas with spaces in one function is invalid, which makes the browser drop the whole declaration.
Key takeaways
- Hex,
rgb(),hsl(),hwb()and the 148 named colours all describe sRGB colours, and a script reads every one of them back asrgb()orrgba()with commas. - Use the modern syntax,
rgb(0 68 204 / 50%); never mix commas and spaces, or the browser drops the declaration. - Equal HSL lightness doesn’t mean equal brightness: at 50%, yellow has 13 times the luminance of blue.
currentColormakes borders, outlines and SVG icons followcolor, in themes and in forced colours mode.- WCAG 2.2 AA asks for 4.5:1 for text, 3:1 for large text and 3:1 for the parts of controls; ratios are never rounded, and translucent colours are measured after they are painted.
References
- CSS Color Module Level 4: the RGB functions and hex notation (W3C CSS Working Group)
- CSS Color Module Level 4: modern and legacy colour syntax (W3C CSS Working Group)
- CSS Color Module Level 4: HSL colours (W3C CSS Working Group)
- CSS Color Module Level 4: HWB colours (W3C CSS Working Group)
- CSS Color Module Level 4: named colours, system colours, transparent and currentcolor (W3C CSS Working Group)
- CSS Color Module Level 4: serializing sRGB values (W3C CSS Working Group)
- Web Content Accessibility Guidelines (WCAG) 2.2: Contrast (Minimum) (W3C)
- Web Content Accessibility Guidelines (WCAG) 2.2: Non-text Contrast (W3C)
- Web Content Accessibility Guidelines (WCAG) 2.2: relative luminance (W3C)
- Understanding Success Criterion 1.4.3: Contrast (Minimum) (W3C Web Accessibility Initiative)
- Technique G183: a contrast ratio of 3:1 to distinguish links from surrounding text (W3C Web Accessibility Initiative)
- <color> (MDN Web Docs) (Mozilla)
- RGB (web-features explorer) (W3C WebDX Community Group)
- HWB (web-features explorer) (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress