Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close