Colour in CSS comes in several notations, and the choice affects how easy a design is to change later. Underneath all of them is one model, and understanding it is what makes opacity, translucency and contrast predictable instead of surprising.
Which ways of writing a colour are there?
/* named */
color: rebeccapurple;
/* hexadecimal */
color: #800d4d;
color: #80d; /* shorthand for #8800dd */
/* rgb */
color: rgb(128, 13, 77);
/* rgb with alpha */
color: rgb(128 13 77 / 0.5);
/* hsl: hue, saturation, lightness */
color: hsl(328, 80%, 28%);
/* hsl with alpha */
color: hsl(328 80% 28% / 0.5);
Hexadecimal is compact and universally understood. rgb() is readable when the numbers come from a design tool. And hsl() is the one to learn for building a palette, because it separates the three things you actually want to vary: the hue is which colour it is, the saturation is how vivid it is, and the lightness is how light or dark it is. Vary the lightness to produce a whole set of tints and shades of the same hue, and you have a palette that is guaranteed to be harmonious.
/* one hue, five lightness steps */
--brand-900: hsl(328 80% 20%);
--brand-700: hsl(328 80% 28%);
--brand-500: hsl(328 80% 45%);
--brand-300: hsl(328 80% 70%);
--brand-100: hsl(328 80% 92%);
What does opacity do to an element?
The opacity property applies to a whole element as a group. Setting it to one half makes the element and everything inside it half transparent, and it creates a new stacking context as a side effect. That is why a faded child of a faded parent is quarter transparent, not half.
.card {
opacity: 0.5; /* the card AND its text are at 50% */
}
That is almost never what you want for a background. What you usually want is a translucent background with fully readable text on top, and that is what an alpha colour gives you.
/* a translucent background, text unaffected */
.card {
background: rgb(128 13 77 / 0.2);
color: #1c1c1c;
}
Keep the distinction straight: opacity fades the element and its contents, and an alpha channel in a colour fades only that colour. For a hover state, a translucent background is almost always the better choice.
What is currentColor?
It is a keyword that means "whatever the colour property is set to on this element". It lets a border or a shadow follow the text colour automatically, so you never have to repeat the value.
.link {
color: #800d4d;
border-bottom: 1px solid currentColor;
}
.button {
color: white;
background: #800d4d;
box-shadow: 0 2px 0 currentColor; /* the shadow is white, matching the text */
}
It is a small feature that removes a whole category of maintenance bug: a button whose text colour changes in a dark theme and whose border does not.
What are the contrast rules?
Contrast is the ratio between the luminance of the text and the luminance of the background behind it, expressed as a number. The widely used thresholds for body text are 4.5 to 1 for normal text, and 3 to 1 for large text — which means text of about 24 pixels, or 18.66 pixels when bold.
#1c1c1c on #ffffff 16.1 to 1 comfortably readable
#5c5c5c on #ffffff 7.0 to 1 fine for body text
#949494 on #ffffff 3.0 to 1 large text only
#ffffff on #800d4d 9.7 to 1 strong
Two practical points. The ratio depends on the actual background, so a grey that passes on white can fail on a light grey card, and text over an image or a gradient needs a scrim behind it to be reliable. And focus indicators, icons and chart colours are not text, so the 3 to 1 threshold applies to them instead.
/* readable text over an image */
.hero-text {
background: rgb(0 0 0 / 0.55);
padding: 1rem;
color: #fff;
}
Where do colours get set?
Almost every visual decision on a page is a colour decision, which is why the same palette has to work for text, borders, backgrounds, icons and charts. The practical technique is to define the palette once as custom properties and then use those names everywhere, so a theme change is a change in one place.
:root {
--surface: #ffffff;
--text: #1c1c1c;
--text-muted: #5c5c5c;
--accent: #800d4d;
--border: #e2e2e2;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: #16161a;
--text: #f2f2f2;
--text-muted: #a5a5a5;
--accent: #ff9ec7;
--border: #33333a;
}
}
body {
background: var(--surface);
color: var(--text);
}
The dark scheme block is worth a mention: the prefers-color-scheme media query lets the page follow the operating system setting, so a reader who prefers dark mode gets it without the site having to guess. Because the palette is expressed as variables, the two themes are the same set of names with different values, and no rule has to change.
How do you adjust a colour without guessing the hex value?
Design systems rarely contain one colour per component. They contain a base colour and a set of predictable variations - a lighter version for a hover state, a transparent version for a background wash, a version with more contrast for text on a busy surface. Being able to derive those from one value is what keeps a palette coherent.
The first tool is the alpha channel in a colour function. rgb(24 92 165 / 40%) gives the same hue and saturation with partial opacity, which is how you tint a background without picking a second colour by hand.
.badge {
background: rgb(24 92 165 / 12%);
color: #185ca5;
border: 1px solid rgb(24 92 165 / 30%);
}
.scrim {
background: rgb(16 36 58 / 65%);
}The second tool is color-mix, which blends two colours and lets you say how much of each you want. It is the cleanest way to build a hover state from a base colour: mix the base with a little more black, and you get a shade that is guaranteed to belong to the same family.
:root {
--brand: #185ca5;
}
.button {
background: var(--brand);
color: #ffffff;
}
.button:hover {
background: color-mix(in srgb, var(--brand) 85%, #000000);
}That hover state is derived rather than invented. If you change --brand later, the hover state follows automatically, and the two can never drift apart.
For dark mode, the same trick works in reverse with a lightening mix, or with the light-dark() function, which returns one of two colours depending on the active colour scheme. The point of all three is the same: one decision in one place, and the variations follow.
Quick reference
- Hex for compactness,
rgb()for readability,hsl()for building palettes from one hue. opacityfades an element and its contents; an alpha channel fades only that colour.- Use an alpha colour for translucent backgrounds, not
opacity. currentColormakes borders and shadows follow the text colour.- Contrast: 4.5 to 1 for body text, 3 to 1 for large text and for non-text indicators.
- Text over an image needs a scrim behind it.
Try it yourself: build a small palette from a single hue by varying only the lightness in hsl(). Then take a mid-grey text colour and test it on white and on a light grey card — you will find one combination passes and the other fails, which is why contrast has to be checked against the real background.