Lesson 17 / 26
Color Representations
Named, hexadecimal, and functional color notations; the conversion between RGB and HSL, and the consequence of color space choice for generating a scale.
Contents
The previous topic established the boxes’ measure, arrangement, and boundaries. The resulting page is structured but colorless: every decision concerned geometry. This topic takes up visual properties, and the first question is color itself.
A color has four separate notations in CSS, and moving between them is done by calculation. Choosing the notation is not a style preference: some notations make changing a color more workable than others, and a design’s color scale therefore gets built in a particular representation.
Four Notations
A named color is a name from a fixed vocabulary: red,
darkslategray, transparent. The vocabulary is fixed and does not
grow; the names do not form a color system, they are a historical
list. It does not get used in design.
Hexadecimal notation writes the red, green, and blue components
in base sixteen: #143a52. The hexadecimal base defined in the How
Computers Work curriculum finds its counterpart here — each component
is one byte, two hexadecimal digits.
Functional notation writes the components explicitly: rgb(20 58 82), hsl(203 61% 20%), oklch(30% 0.06 240). The function name
says which color space the numbers get read in.
Keywords take their color from context: currentcolor gives the
element’s color value, transparent gives fully transparent black.
The Conversion Between RGB and HSL
RGB and HSL define the same set of colors; their difference is the coordinate system. RGB uses three light components, HSL uses the hue, saturation, and lightness axes. The conversion is two-directional and lossless.
// color.mjs — converts between hex, rgb, and hsl representations function hexToRgb(hex) { let h = hex.replace("#", ""); if (h.length === 3) h = [...h].map((c) => c + c).join(""); if (h.length === 4) h = [...h].map((c) => c + c).join(""); const r = parseInt(h.slice(0, 2), 16); const g = parseInt(h.slice(2, 4), 16); const b = parseInt(h.slice(4, 6), 16); const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1; return { r, g, b, a }; } function rgbToHsl({ r, g, b }) { const rn = r / 255, gn = g / 255, bn = b / 255; const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn); const diff = max - min; const l = (max + min) / 2; let h = 0, s = 0; if (diff !== 0) { s = diff / (1 - Math.abs(2 * l - 1)); if (max === rn) h = 60 * (((gn - bn) / diff) % 6); else if (max === gn) h = 60 * ((bn - rn) / diff + 2); else h = 60 * ((rn - gn) / diff + 4); if (h < 0) h += 360; } return { h: Math.round(h), s: +(s * 100).toFixed(1), l: +(l * 100).toFixed(1) }; } const colors = ["#143a52", "#8a1c1c", "#f5f7f8", "#fff", "#1c2733", "#8a5b00", "#d5dbe0"]; console.log("hex rgb() hsl()"); for (const hex of colors) { const c = hexToRgb(hex); const h = rgbToHsl(c); console.log( `${hex.padEnd(11)} rgb(${c.r} ${c.g} ${c.b})`.padEnd(34) + `hsl(${h.h} ${h.s}% ${h.l}%)` ); } console.log("\n--- expansion of short forms ---"); for (const hex of ["#fff", "#0a3", "#143a52cc"]) { const c = hexToRgb(hex); console.log(`${hex.padEnd(11)} -> rgb(${c.r} ${c.g} ${c.b}) alpha=${c.a.toFixed(3)}`); } console.log("\n--- generating a scale by varying one hue's lightness ---"); const base = rgbToHsl(hexToRgb("#143a52")); for (const l of [12, 24, 36, 50, 72, 90, 96]) { console.log(`hsl(${base.h} ${base.s}% ${l}%)`); }
hex rgb() hsl() #143a52 rgb(20 58 82) hsl(203 60.8% 20%) #8a1c1c rgb(138 28 28) hsl(0 66.3% 32.5%) #f5f7f8 rgb(245 247 248) hsl(200 17.6% 96.7%) #fff rgb(255 255 255) hsl(0 0% 100%) #1c2733 rgb(28 39 51) hsl(211 29.1% 15.5%) #8a5b00 rgb(138 91 0) hsl(40 100% 27.1%) #d5dbe0 rgb(213 219 224) hsl(207 15.1% 85.7%) --- expansion of short forms --- #fff -> rgb(255 255 255) alpha=1.000 #0a3 -> rgb(0 170 51) alpha=1.000 #143a52cc -> rgb(20 58 82) alpha=0.800 --- generating a scale by varying one hue's lightness --- hsl(203 60.8% 12%) hsl(203 60.8% 24%) hsl(203 60.8% 36%) hsl(203 60.8% 50%) hsl(203 60.8% 72%) hsl(203 60.8% 90%) hsl(203 60.8% 96%)
Hexadecimal Notation’s Shorthands
The output’s second block unpacks three shorthands.
In the three-digit writing, every digit gets doubled: #fff
means #ffffff, #0a3 means #00aa33. This means the number of
colors that can get written with three digits is 4096 — a small
subset of the full set. #0a3 is not an approximation, it is exactly
#00aa33.
In the eight-digit writing, the last two digits carry the alpha
channel. In the writing #143a52cc, cc’s hexadecimal value is
204, and dividing it by 255 gives 0.8. The alpha channel gets taken up
in the next lesson.
Color Space Choice Is a Scale Decision
In the output’s first block, the color #143a52 gets written as
hsl(203 60.8% 20%). The two representations give the same color;
their difference shows up once change is needed.
If a lighter tone of this color needs producing, in hexadecimal notation all three components have to get calculated separately, and the hue can drift. In HSL notation, only the third number gets changed; hue and saturation stay fixed. The output’s third block shows this: a seven-step scale got produced from a single hue, by changing only the lightness value.
A design’s color scale therefore gets built in a hue-axis
representation. HSL has a known limitation: equal lightness
differences do not correspond to equal perceived differences. At
the same lightness value, yellow looks noticeably brighter than blue.
Perceptually more uniform spaces like oklch() are defined to reduce
this problem, and have the same three axes: lightness, chroma, hue.
Whether a representation is recognized by implementations can get tested:
.warning { background-color: #8a5b00; } @supports (color: oklch(50% 0.1 40)) { .warning { background-color: oklch(45% 0.12 75); } }
The block gets ignored if the condition is not met, and the first
declaration stays in place. The “an invalid declaration gets dropped”
rule seen in previous lessons would give the same result; writing
@supports makes the intent explicit.
Color Variables
A color scale should get defined once and called by name everywhere. The tool for this is custom properties:
:root { --hue: 203; --brand-dark: hsl(var(--hue) 61% 20%); --brand-mid: hsl(var(--hue) 61% 36%); --brand-light: hsl(var(--hue) 61% 90%); --surface: hsl(var(--hue) 18% 97%); --text: hsl(211 29% 16%); --warning: hsl(0 66% 33%); }
Custom properties get defined with a double-hyphen prefix and get
read with var(). They are inherited properties: a value defined on
the root element can get read throughout the whole subtree. This also
makes it possible to change the value for a subtree by redefining it
inside a component.
This writing has a side effect: because --hue is kept in one place,
the whole scale can get shifted by changing a single number.
One warning is needed: a custom property’s value does not get
validated during parsing. If something invalid gets written into
--brand-dark’s value, the error shows up where the value gets used,
and that declaration drops.
The Station Page’s Color Scale
/* station.css — step 16: color scale */ :root { --hue: 203; --brand-dark: hsl(var(--hue) 61% 20%); --brand-light: hsl(var(--hue) 61% 90%); --surface: hsl(var(--hue) 18% 97%); --line: hsl(207 15% 86%); --text: hsl(211 29% 16%); --text-muted: hsl(211 12% 45%); --warning: hsl(0 66% 33%); --caution: hsl(40 100% 27%); } body { color: var(--text); } .masthead { background-color: var(--surface); border-block-end-color: var(--brand-dark); } .measurement-table th, .measurement-table td { border-block-end-color: var(--line); } .missing { color: var(--warning); } .source-note { color: var(--text-muted); }
The names in the scale name function, not color: --warning,
--line, --text-muted. This is the naming principle from selectors
applied to colors. A variable named --red starts lying once the
warning color gets changed to orange.
The currentcolor keyword is useful together with this scale: writing
border-color: currentcolor on a border ties the border to the text
color, and once the .missing class turns the text red, the border
changes along with it. A single declaration keeps two properties
synchronized.
Summary
- A color gets written in four forms: from the named vocabulary, in hexadecimal notation, in functional notation, and with keywords that take color from context.
- In hexadecimal notation, the three-digit form doubles every digit; in the eight-digit form, the last two digits carry the alpha channel.
- RGB and HSL define the same set of colors with different coordinates; the conversion is two-directional, and the choice of representation determines how workable changing a color is.
- A color scale gets built in a hue-axis representation: a consistent sequence of steps gets produced by changing a single hue’s lightness.
- Color variables name function, not color; they get defined as inherited custom properties and can get changed locally by getting redefined in a subtree.
Next Step
This lesson treated colors as fully opaque; the alpha channel seen in the eight-digit writing went by unexplained. What color results when a transparent color overlaps another, and how does that color’s contrast with text get calculated? The next lesson takes up alpha blending and the WCAG contrast ratio, numerically.
To keep your progress and take notes, Log in
My notes
Log in to take notes.