Become a Professional Frontend Developer
8 min read

CSS Color & Gradients: From Hex to oklch and Beyond

A complete, practical guide to color on the web — hex, rgb and hsl, the modern oklch space, alpha and transparency, currentColor, color-mix, linear/radial/conic gradients, color stops and hard stops, multiple backgrounds and background-clip, dark mode with prefers-color-scheme, contrast and accessibility, common mistakes, and hands-on exercises with solutions.

Color is where a design either feels coherent or falls apart, and CSS now gives you far better tools for it than the hex codes most people still reach for by reflex. This is the full picture: the color models and when each one helps, how transparency and currentColor cut repetition, the three gradient types, and the two things that actually matter most — contrast for accessibility and a clean dark mode. Pair this with the colour theory in modern CSS and the legibility work in typography and your pages will read clearly for everyone.

Pretty colours are easy; readable colours are the job. The one rule you can't skip: body text needs a contrast ratio of at least 4.5:1 against its background (3:1 for large text). Everything else in this post is in service of that.

The Color Models

CSS accepts color in several notations. They can all describe the same pixel — they differ in how easy they are to reason about:

color: #3366ff;                 /* hex — compact, but opaque to the human eye */
color: rgb(51 102 255);         /* red/green/blue, 0–255 (or %) */
color: rgb(51 102 255 / 0.5);   /* + alpha after the slash */
color: hsl(225 100% 60%);       /* hue, saturation, lightness — human-friendly */
color: hsl(225 100% 60% / 0.5); /* hsl with alpha */
color: oklch(58% 0.22 264);     /* perceptually uniform — the modern default */
  • hex / rgb — universal, but you can't eyeball "make this 10% lighter."
  • hsl — readable (rotate the hue, raise the lightness), which is why it was the go-to for generating variations. Its weakness: equal lightness numbers don't look equally light across hues.
  • oklch — a perceptually uniform space (lightness, chroma, hue). A 10% lightness change looks like 10% everywhere, so palettes derived from it are even and predictable. It also reaches vivid colours on modern displays that sRGB can't. This is the recommended default for new work (covered in depth in modern CSS).

Note the modern space-separated syntax (rgb(51 102 255 / 0.5)) — the old comma form (rgba(51,102,255,0.5)) still works, but the new one unifies rgb/rgba into one function with an optional / alpha.

Alpha, Transparency, and currentColor

Two keywords and a value cut a surprising amount of repetition:

.overlay { background: rgb(0 0 0 / 0.4); }   /* 40% black scrim */
.divider { border-color: rgb(255 255 255 / 0.15); }

.icon { fill: currentColor; }   /* take the element's own `color` value */
.btn  { color: white; }
.btn .icon { /* automatically white, because currentColor follows color */ }

currentColor is the original design token: it resolves to the element's computed color, so an SVG icon, a border, or a box-shadow can track the text colour automatically — change color once and they all follow. transparent is just rgb(0 0 0 / 0) and is invaluable in gradients (below).

Deriving Colors with color-mix()

Instead of hand-picking a hover shade or a tint, mix colours right in CSS — so a whole palette derives from one base variable:

:root { --brand: oklch(58% 0.22 264); }

.btn        { background: var(--brand); }
.btn:hover  { background: color-mix(in oklch, var(--brand), black 12%); } /* darker */
.badge      { background: color-mix(in oklch, var(--brand), white 80%); } /* light tint */
.muted-text { color: color-mix(in srgb, currentColor 60%, transparent); } /* fade */

This replaces the Sass colour functions teams used to depend on — hovers, tints, shades, and translucent variants all derived from a single token, no build step. (More in modern CSS.)

Gradients

A gradient is an image generated by CSS, so it goes wherever an image can — most often background. There are three kinds.

Linear — color transitions along a line at an angle:

.hero {
  background: linear-gradient(to right, #6a11cb, #2575fc);
}
.sky {
  background: linear-gradient(180deg, #87ceeb 0%, #ffffff 100%);  /* top to bottom */
}

Radial — color radiates outward from a point:

.spotlight {
  background: radial-gradient(circle at 30% 20%, #fff, #ddd 60%, #999);
}

Conic — color sweeps around a center, like a color wheel or pie chart:

.pie {
  background: conic-gradient(#e11 0 40%, #1a1 40% 75%, #14e 75% 100%);
}

Color Stops and Hard Stops

The numbers after each color are stops — where that color "lands." Place two colors at the same position and the blend becomes a crisp line, which is how you draw stripes with no images:

/* a smooth three-color band */
background: linear-gradient(90deg, red 0%, gold 50%, green 100%);

/* hard stops → solid stripes (note the repeated positions) */
.stripes {
  background: repeating-linear-gradient(
    45deg,
    #f4f4f4 0 10px,        /* color from 0 to 10px */
    #e0e0e0 10px 20px      /* next color from 10 to 20px — a sharp edge */
  );
}

repeating-linear-gradient (and its radial/conic siblings) tile the pattern automatically — perfect for stripes, hatching, and progress bars.

Layering: Multiple Backgrounds & background-clip

A single element can stack multiple backgrounds, first listed on top, each with its own size and position:

.card {
  background:
    linear-gradient(rgb(0 0 0 / 0.5), rgb(0 0 0 / 0.5)),  /* a darkening scrim on top */
    url("/photo.jpg") center / cover no-repeat;            /* the photo underneath */
}

That scrim-over-photo pattern (a gradient layered above an image) is the standard way to keep overlaid text readable. And background-clip: text paints a gradient through the letters themselves:

.gradient-text {
  background: linear-gradient(90deg, #6a11cb, #2575fc);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

Dark Mode with prefers-color-scheme

Respect the user's OS theme. Build your colours on custom properties and swap them in a media query — the rest of the CSS doesn't change:

:root {
  --bg: white;
  --text: oklch(20% 0 0);
}
@media (prefers-color-scheme: dark) {
  :root {
    --bg: oklch(18% 0.01 264);
    --text: oklch(92% 0 0);
  }
}
body { background: var(--bg); color: var(--text); }

Because every colour reads from a variable, dark mode is just a second set of values — no duplicated rules. Add color-scheme: light dark on :root so native controls (form fields, scrollbars) theme correctly too.

Contrast and Accessibility

This is the part that's not optional. Text must stand out enough from its background for people with low vision (and anyone in bright sunlight) to read it. The WCAG thresholds:

  • 4.5:1 minimum for normal body text,
  • 3:1 for large text (≈24px, or 18.66px bold) and meaningful UI/icons.

Check it in DevTools (the colour picker shows the ratio and flags failures) before shipping. Two practical habits:

  • Never rely on colour alone to convey meaning — a red "error" also needs an icon or text, for colour-blind users.
  • Test your dark mode separately — a palette that passes in light mode can fail when inverted.

Common Mistakes

  • Hard-coding hex everywhere instead of building on custom properties, making theming and dark mode painful.
  • Shipping text that fails the 4.5:1 contrast minimum — the single most common accessibility defect.
  • Generating tints/shades in hsl and getting uneven steps — oklch is perceptually uniform.
  • Conveying state with colour alone (red/green) with no icon or label — invisible to colour-blind users.
  • Repeating a colour on icons, borders, and shadows instead of using currentColor.
  • Forgetting color-scheme on :root, so native form controls stay light in dark mode.
  • Overlaying text on a busy photo with no scrim gradient, making it unreadable on light areas.
  • Using rgba()/hsla() out of habit — the unified rgb(... / a) / hsl(... / a) syntax is the current form.

Exercises

Try each before opening the solution.

Exercise 1 — A translucent scrim

Darken any background by 40% so white text on top stays readable, using modern color syntax.

Show solution
.scrim { background: rgb(0 0 0 / 0.4); }

rgb(0 0 0 / 0.4) is 40%-opaque black — the modern space-separated syntax with alpha after the slash. Layer it over an image to guarantee text contrast.

Exercise 2 — Derive a hover shade

Given --brand, make the :hover background 15% darker without declaring a second colour.

Show solution
.btn:hover {
  background: color-mix(in oklch, var(--brand), black 15%);
}

color-mix blends 15% black into the brand colour in the perceptually-uniform oklch space, so the darkening looks even — and it's derived from the single --brand token.

Exercise 3 — CSS-only stripes

Create a diagonal striped background with no image, using two greys.

Show solution
.stripes {
  background: repeating-linear-gradient(
    45deg,
    #f0f0f0 0 12px,
    #e2e2e2 12px 24px
  );
}

The repeated stop positions (0 12px, then 12px 24px) create hard edges instead of a blend, and repeating-linear-gradient tiles the two-colour band across the element.

Exercise 4 — Themeable dark mode

Set up --bg/--text variables that automatically flip for users whose OS is in dark mode.

Show solution
:root { color-scheme: light dark; --bg: #fff; --text: #1a1a1a; }
@media (prefers-color-scheme: dark) {
  :root { --bg: #161616; --text: #ededed; }
}
body { background: var(--bg); color: var(--text); }

The variables hold the theme, the media query swaps their values, and every element that reads them updates for free. color-scheme makes native controls match too.

The Mental Model to Keep

Describe colour in the model that fits the task: hex/rgb for fixed values, but reach for oklch when you want palettes that scale evenly and vivid modern colours. Cut repetition with currentColor, transparency via the unified rgb(... / a) syntax, and derive hovers/tints/shades with color-mix() from a single base token. Gradients are CSS-generated images — linear, radial, conic — where shared stop positions make hard-edged stripes, and you can layer several backgrounds (a scrim over a photo) on one element. Build everything on custom properties so dark mode is just a second set of values behind prefers-color-scheme. And never lose sight of the one non-negotiable: contrast — 4.5:1 for body text — because colour that can't be read isn't design, it's decoration.