1. Presentation
  2. Color
  3. Color in CSS

Presentation · Color 03

Color in CSS

CSS has one type for color and a dozen ways to write it. The way you choose decides which colors you can name, how easily a person can read and adjust them, and what your scripts get back when they ask. This page teaches the notations as three generations, makes oklch() the default for new work, and ends by turning a designer's hex values into tokens, as this site did with its own theme.

CSS Values introduced the color functions in a paragraph. This page is the full treatment: what each notation means, how to read the one you will use most, what happens to a color that no screen can show, where color applies in a style sheet, and how scripts and readers work with colors at run time. Two demos let you measure what the browser does, and the site's own theme serves as the worked example at the end.

1. One type, three generations

Every property that takes a color accepts the same value type, <color>, and every notation is a way of writing a value of that type. color: #c41e3a and color: oklch(53.08% 0.1969 19.54) name the same red, and the browser treats both as a color, not as text. So the choice of notation is not about what CSS can do with the value. It is about three other things:

  • The space the numbers live in. A color is a set of numbers plus the color space that gives them meaning, the way a point is a pair of numbers plus a coordinate system. The same three numbers mean different colors in sRGB, in Display P3 and in OKLCH.
  • What a person can do with it. Some notations can be read and adjusted by hand: lighter, less colorful, the same color in another hue. Others can only be copied and pasted.
  • Its reach. Some notations can only name the colors of the sRGB space, the standard for the web since the 1990s. Others can name every color a modern screen can show, and more.

The notations arrived in three generations, and each answered a limit of the one before:

  1. Keywords and hex. Names such as red and rebeccapurple, and hexadecimal values such as #c41e3a, came to CSS from HTML's color attributes. Both describe sRGB colors with eight bits per channel.
  2. sRGB functions. rgb(), hsl() and later hwb() write the same sRGB colors as numbers a person can see and change. They give friendlier knobs, but no new colors.
  3. Device-independent spaces. lab(), lch(), oklab(), oklch() and color() describe colors in spaces built around human vision or around named display standards, so they reach past sRGB to the wider gamuts of current phones and laptops.

The third generation became Baseline in May 2023, when the last major engine shipped it, and widely available in November 2025, thirty months later. That is why this section uses oklch() for every token and shows hex only for comparison: it is the notation that is both readable and able to reach the whole of a modern screen. Hex will not go away, because design tools export it and every developer can read it at a glance, so the habit worth building is converting it once, at the border between design and code. Section 11 does exactly that.

2. The notations

Here is one red, the kind a designer might hand over for a "Sale" badge, in every notation. Each row names the same color, rounded, except the keyword row, because this red has no name.

One red in every notation (#c41e3a, converted by Chrome 156)
NotationThe same redSpaceStrengthWeakness
Named keywordcrimson is the nearest name, and it is #dc143c, a different redsRGBReadableAn arbitrary list, not a palette: darkgray is lighter than gray
Hex#c41e3a; with alpha #c41e3a80sRGBCompact, universal, the design-tool defaultOpaque to people; cannot reach wide gamut
rgb()rgb(196 30 58); rgb(196 30 58 / 50%)sRGBMatches how screens mix lightHard to adjust by hand
hsl()hsl(350 73.5% 44.3%)sRGBHue, saturation and lightness knobsIts lightness is not perceived lightness
hwb()hwb(350 11.8% 23.1%)sRGBA paint-mixing model: a hue plus white and blackThe same perceptual flaw as HSL
lab(), lch()lab(43.46% 63.28 30.11); lch(43.46% 70.08 25.45)CIELABPerceptual, device-independentBlues drift toward purple as they change
oklab(), oklch()oklab(53.08% 0.1856 0.0659); oklch(53.08% 0.1969 19.54)OklabEven lightness, stable hue, reaches P3 and beyondCan name colors no screen shows
color()color(display-p3 0.706 0.193 0.245)Named: srgb, display-p3, rec2020, xyz and moreAn exact space and exact numbersThe least readable
SpecialcurrentColor, transparent, Canvas, AccentColorn/aInherit, clear, or follow the systemSystem colors vary by platform and theme

Named colors

A named color is a keyword for a fixed sRGB value, and keywords are case-insensitive. The list grew by accretion: browsers accepted names from 1995 and about 140 by 1996, mostly from the X Window System; the first standards kept only the sixteen Windows VGA colors among them; SVG 1.0 and then CSS Color 3, in 2011, standardized all of them, and rebeccapurple followed in 2014. Page 02 of this section, How color works, tells that history. What matters here is that the list is a vocabulary, not a system. darkgray is #a9a9a9 and gray is #808080, so "dark" gray is the lighter of the two: OKLCH lightness 73.5% against 60%. Names are fine for teaching, for a quick prototype and for transparent, and poor as a palette, because a brand's colors almost never have names.

Hex

A hex color writes the red, green and blue channels as two hexadecimal digits each, from 00 to ff (0 to 255). Four forms are valid: #rgb and #rrggbb, and the same with an alpha digit, #rgba and #rrggbbaa. In #c41e3a80 the alpha is 80, 128 out of 255, a little over 50 percent. Hex is compact and every tool reads it, which is why a designer's handoff is almost always hex. Its weaknesses are the reverse of its strengths: nobody can tell by looking whether #c41e3a is lighter than #3a8f5c, and it can only name sRGB colors, so it cannot reach the extra colors of a P3 screen.

rgb(), hsl() and hwb()

The three sRGB functions name exactly the colors hex can name, in more readable forms. rgb() writes the channels as numbers from 0 to 255 or as percentages. hsl() describes the same cube as a cylinder: a hue angle around a color wheel, a saturation, and a lightness from black through the pure color to white. hwb() starts from a pure hue and adds white and black, which matches how people mix paint. HSL's knobs feel natural, and for years they were the best CSS had for "the same color, a bit lighter". But HSL is arithmetic on the RGB cube, not a model of vision, and its lightness is not what people see. Section 4 measures how far off it is.

lab(), lch(), oklab(), oklch() and color()

The third generation describes colors in spaces that do not depend on any one display. CIELAB, written lab(), comes from color science in the 1970s and was designed to be perceptually uniform: equal steps in its numbers should look like equal steps in color. lch() is the same space in cylindrical form, with lightness, chroma and hue. It mostly works, but its hue is unreliable in the blues, which drift toward purple as they get lighter or less colorful.

Oklab, published by Björn Ottosson on 23 December 2020, keeps Lab's shape and fixes its hue predictions, with simpler math. oklab() writes it in rectangular form, a lightness and two axes, a (green to red) and b (blue to yellow), which suits calculations. oklch() writes the same space as lightness, chroma and hue, which suits people. Both can describe any color the eye can see, so a screen's gamut is a region inside them, not their boundary.

color() names the space outright and gives its channels as numbers: color(display-p3 1 0 0) is the reddest red of the Display P3 standard, redder than anything in sRGB. Its predefined spaces include srgb and srgb-linear, display-p3, a98-rgb, prophoto-rgb, rec2020, and the CIE XYZ spaces xyz, xyz-d50 and xyz-d65. It is the exact notation for a color specified in a given standard, and the least readable, which is why it suits machine-made values rather than hand-written tokens.

Keywords that are not colors you choose

  • currentColor is the element's own color value. It is the most useful keyword in this page and gets section 7 to itself.
  • transparent is fully transparent black, the initial value of background-color.
  • System colors name the colors of the reader's own platform and theme: Canvas and CanvasText for a page's background and text, LinkText and VisitedText, ButtonFace and ButtonText, Field and FieldText, Highlight and HighlightText, GrayText, and more. They follow the reader's light or dark setting through color-scheme, and under forced colors they are the reader's chosen palette, which is why this section's demos use them for their own text. AccentColor and AccentColorText are meant to be the system's accent, but not yet in practice: Firefox (since 103), Safari (since 16.5) and Chrome (since 149, on the desktop) all return a fixed blue rather than the reader's chosen accent, except Chrome inside installed web apps. They are not yet a way to match the operating system.

3. Modern syntax

CSS Color Level 4 gave every color function one shape: channels separated by spaces, then an optional slash and an alpha value. The older comma-separated form still works for rgb() and hsl(), and rgba() and hsla() are now plain aliases of them, so the "a" adds nothing.

/* Modern: spaces between channels, a slash before alpha */ .badge { color: rgb(196 30 58); background-color: oklch(53% 0.197 19.5 / 15%); border-color: hsl(350 73.5% 44.3% / 0.5); } /* Legacy: commas, and a separate function for alpha. Still valid. */ .badge-old { color: rgb(196, 30, 58); background-color: rgba(196, 30, 58, 0.15); }

The rules are few:

  • Spaces or commas, never both. rgb(196, 30 58) is invalid. The new functions (hwb(), lab(), oklch(), color() and the rest) accept only spaces.
  • Alpha after a slash, as a number from 0 to 1 or a percentage. Leave it out for an opaque color.
  • Numbers or percentages. In the modern syntax most channels take either. oklch(53% …) and oklch(0.53 …) are the same lightness, and in oklch() a chroma of 100% means 0.4.
  • Hues are angles. A bare number means degrees; deg, turn and rad work too, so 0.5turn is 180.
  • none marks a missing channel. oklch(50% 0 none) is a gray with no hue. A missing channel behaves as zero when the color is drawn, but it matters when colors are mixed or interpolated, which page 04, Deriving color, explains.

Out-of-range values are clamped where they have a range: oklch(120% 0.1 30) computes to a lightness of 1, and an alpha above 1 becomes 1. Chroma has no upper limit in the syntax, which leads to the question of section 5.

4. Reading oklch()

An oklch() value has three channels, and each answers a question a designer would ask about a color:

  • L, lightness: how light does it look? 0% is black and 100% is white, and the steps between are meant to look even.
  • C, chroma: how colorful is it? 0 is a gray. The most colorful sRGB colors, magenta and blue, reach about 0.32 and 0.31, P3 colors a little more, and the specification notes that chroma "in practice does not exceed 0.5".
  • H, hue: which color is it? An angle around a wheel. Red is near 29, yellow near 110, green near 142, blue near 264.
The three channels of oklch(): lightness, chroma and hueLeft: a vertical lightness axis with five gray chips, labeled 0% black, 25%, 50%, 75% and 100% white. Middle: a hue wheel seen from above. Its center is gray, with chroma 0; an arrow from the center outward is labeled C, chroma, growing toward the edge. Six swatches sit around the rim, each at lightness 70% and chroma 0.12, labeled with their hue angles: 29 red, 110 yellow, 142 green, 196 cyan, 264 blue and 328 magenta. Right: the notation oklch(L C H / A), with the ranges: L from 0 to 100%, C from 0 to about 0.32 for sRGB colors, H from 0 to 360 degrees. Holding L and C and turning H gives about the same lightness in every hue.L: lightness100% white75%50%25%0% blackC 0: grayC29 red110 yellow142 green196 cyan264 blue328 magentaH: hue angleoklch(L C H / A)L 0 to 100%C 0 to about 0.32 in sRGBH 0 to 360 degreesHold L and C, turn H:about the same lightnessin every hue.
OKLCH is a cylinder: lightness runs up the middle, chroma out from it, hue around it. The six swatches share L 70% and C 0.12.

Read oklch(53% 0.197 19.5) aloud and it describes itself: a middle lightness, quite colorful, a red leaning slightly toward pink. Change one number and you know what will happen. oklch(63% 0.197 19.5) is the same red, lighter; oklch(53% 0.08 19.5) is the same red, duller; oklch(53% 0.15 145) is a green that looks about as light. Only lch() offers the same three moves, and its hue drifts in the blues.

Note that the hue angles do not match HSL's. HSL puts red at 0, yellow at 60, green at 120 and blue at 240, evenly spaced on the RGB cube. OKLCH spaces hues by how they look, so the same primaries land at about 29, 110, 142 and 264. A hue copied from hsl() into oklch() gives a different color.

What HSL's lightness gets wrong

The case for oklch() fits in one experiment: write twelve hues at the same lightness, in both notations, and measure them. The quantity to measure is relative luminance, written Y: how much light a color sends to the eye, weighted by how sensitive the eye is to each wavelength, from 0 for black to 1 for white. It is the number WCAG contrast ratios are built on, and page 02, How color works, explains it.

hsl(60 100% 50%) and hsl(240 100% 50%) both claim a lightness of 50%. The first is yellow with a luminance of 0.93; the second is blue with a luminance of 0.07, thirteen times darker. HSL computes its lightness as the average of the largest and smallest RGB channel, which ignores that the eye is far more sensitive to green than to blue. OKLCH was fitted to measurements of perception, so equal L looks close to equally light across hues.

That is the third sin of the broken store. Terra Outfitters' Trail report on discolored. marks it as SIN #3: two buttons, hsl(55 100% 50%) and hsl(230 100% 50%), with the same saturation and lightness. They draw as #ffea00 and #002bff, and white text on them measures 1.23:1 and 7.53:1. The fixed Trail report uses oklch(50% 0.1 85) and oklch(50% 0.15 255) in light mode: white text measures 6.04:1 and 6.07:1, and the buttons carry the same weight.

The strip is also a lesson in access. The gray chips are roughly what the colors look like to someone who cannot see their hue, and in the HSL row a reader with no color vision sees one row of very different grays where the designer meant twelve equal colors. Equal lightness is what lets a palette hold together for every reader, and it is what makes a whole set of tokens predictable, which page 05, Choosing and pairing, builds on.

5. Colors no screen can show

Because oklch() can describe any color the eye can see, it can also describe colors a screen cannot show. A gamut is the set of colors a device or color space can produce; sRGB's gamut is smaller than Display P3's, which is smaller than Rec. 2020's. Push the chroma of oklch() far enough, at any lightness, and the color leaves sRGB, then P3, and eventually everything. Such a value is still valid CSS. The browser keeps it as written: getComputedStyle() returns oklch(0.6 0.4 30) for a red no screen can show.

The question is what gets drawn. CSS Color 4 says that when a color is drawn on a display, an out-of-gamut value "must be converted to an in-gamut color", and it defines CSS gamut mapping for doing it: reduce the chroma in OKLCH, keeping lightness and hue, until the color fits. The October 2026 draft lets a browser choose among three algorithms for that search (binary search, EdgeSeeker and ray tracing), all aiming at "constant-lightness, constant-hue chroma reduction". It calls simple clipping of each channel "the least acceptable method", because clipping changes the proportions of red, green and blue and so shifts the hue.

What browsers do is another matter, and it is worth measuring. Chrome 156, drawing to an sRGB screen, clips. oklch(90% 0.3 250) is a light, intense blue; Chrome draws it as #00dfff, which is oklch(83% 0.145 213): darker by seven points of lightness and turned 37 degrees toward cyan. The specification's chroma reduction gives #bae3ff, about oklch(89.6% 0.058 238): nearly the same lightness, a hue within a few degrees of blue, and much less color; Displays and environments compares the two methods. So the same out-of-gamut token can look different in two browsers, and different from what its numbers promise.

Three habits follow:

  • Keep chroma moderate for colors that must match: brand colors, text and anything with a contrast requirement. A color inside sRGB is drawn the same by every browser, and its contrast ratio can be computed. The lightness strip at chroma 0.1 stays inside sRGB from 60% to 80% lightness for every hue.
  • Treat wide-gamut color as an enhancement. Write the sRGB color first, then the vivid one for screens that can show it, chosen with the color-gamut media feature. Page 07, Displays and environments, shows how.
  • Check the boundary with a tool. oklch.com, by Evil Martians, draws the sRGB and P3 boundaries as you move the sliders, and the case for OKLCH by Andrey Sitnik and Travis Turner is a widely cited argument for switching.

6. Where color applies

CSS Properties introduced color. It is one of many places a color goes, and most of the others take their default from it:

Where color applies, and what each place defaults to
WherePropertyDefault
TextcolorInherited from the parent; the root gets the browser's text color (CanvasText)
Backgroundsbackground-color, and gradients in background-imagetransparent
Linesborder-color, column-rule-colorcurrentColor
Outlinesoutline-colorauto: usually currentColor, or the platform's focus color
Text decorationtext-decoration-color, text-emphasis-colorcurrentColor
Shadowsbox-shadow, text-shadowcurrentColor when the color is left out
Text inputcaret-colorauto, which is usually currentColor
Form controlsaccent-colorauto: the platform's accent
Scrollbarsscrollbar-colorauto: the platform's scrollbar
SVGfill, stroke, stop-color, flood-colorfill is black, stroke is none
Pseudo-elements::selection, ::marker, ::placeholder, ::target-textThe browser's own styles; only some properties apply

Two of these deserve a word about support. accent-color, which tints checkboxes, radio buttons, range sliders and progress bars, has worked in Chrome since 93 and Firefox since 92; Safari 15.4 accepted it without keeping the control's contrast legible, and MDN's data, updated October 2026, records full support only from Safari 26.2. Chrome for Android has the same contrast problem (Chromium bug 343503163), so webstatus.dev lists it as limited availability; treat it as polish. scrollbar-color arrived in Firefox 64, Chrome 121 and Safari 26.2, and became Baseline in December 2025.

The color property is special in two ways. It is inherited, so setting it on a container colors all the text inside, and it defines currentColor for the element, so every property in the table that defaults to currentColor follows it. A border: 1px solid with no color is a border in the text color, and changing the text color changes the border.

::selection { color: var(--on-accent); background-color: var(--accent); } li::marker { color: var(--accent); } input::placeholder { color: var(--text-muted); /* placeholder text still needs 4.5:1 */ }

Each of these is a text and background pair, and each needs the contrast check that page 08, Color for people, describes. Placeholder text is the classic failure: designers make it pale to look like a hint, and it ends up unreadable.

7. currentColor as a habit

A style sheet that names its text color once and lets everything else follow it with currentColor has fewer colors to maintain, and those colors survive changes nobody planned for. Icons, borders, underlines and focus rings are the usual candidates:

<a href="/cart"> <svg viewBox="0 0 24 24" aria-hidden="true" width="24" height="24"> <path fill="currentColor" d="M7 18a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm10 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM5.2 4H2V2h4.8l3.6 9h7.4l2.4-6H8.6l-.8-2H23l-4 10H9.6Z"/> </svg> Cart </a> .button { color: var(--accent-text); border: 1px solid; /* currentColor by default */ background-color: transparent; } .button:hover { color: var(--accent-text-hover); /* border and icon change with it */ } .button:focus-visible { outline: 0.1875rem solid currentColor; outline-offset: 0.125rem; }

The payoff comes when someone else changes the color. A dark theme sets --accent-text to a lighter value, and the icon, the border and the focus ring follow. A hover state changes one property instead of four. And under forced colors, the mode in which the operating system replaces a page's colors with the reader's own palette (Windows calls it a contrast theme), the browser overrides color with a system color, so everything drawn in currentColor takes the reader's color too. In Chrome 156 under forced colors, an icon with a fixed fill="#1f2430" keeps that dark fill on what may now be a black background, while an icon with fill="currentColor" turns the reader's text color and stays visible. Page 08, Color for people, covers forced colors in depth, and Type for people shows the same respect for the reader's settings in type.

The focus ring is where this matters most. Removing the focus outline is the inaccessible store's SIN #4a, one rule, *:focus { outline: none; }, with nothing in its place, and a keyboard user loses their place on every page. A ring in currentColor, offset from the element, stays visible in light, dark and forced colors alike.

8. Asking first: @supports and CSS.supports()

The newest color notations are a progressive enhancement, and CSS has two ways to enhance. The first needs no syntax at all. A browser that cannot parse a declaration drops it and keeps the one before, so writing a fallback first and the newer value second works everywhere:

.sale-badge { background-color: #c41e3a; /* every browser */ background-color: oklch(53% 0.197 19.5); /* browsers that parse oklch() */ }

This works because the second declaration is thrown away while the style sheet is parsed. It does not work through custom properties: a browser stores --accent: oklch(…) without checking it, and when var(--accent) later turns out to be unusable, the declaration becomes invalid at computed-value time and the property falls back to its inherited or initial value, not to the line before. Page 04, Deriving color, shows the consequence for tokens. When several declarations must change together, or a fallback runs through custom properties, ask explicitly with @supports, which Responsive design introduced:

:root { --accent: #c41e3a; --accent-text: #a1172e; } @supports (color: oklch(0% 0 0)) { :root { --accent: oklch(53% 0.197 19.5); --accent-text: oklch(46% 0.17 19.5); } }

Scripts ask the same question with CSS.supports(), in either of two forms: a property and a value, or a whole condition in parentheses.

CSS.supports('color', 'oklch(0% 0 0)'); // true in current browsers CSS.supports('(color: oklch(from red l c h))'); // relative color syntax CSS.supports('color', 'contrast-color(red)'); // newer still

For oklch() itself the question is now mostly historical: every current browser says yes, and the fallback matters only for readers on browsers from before 2023. The habit matters for what comes next. Relative colors became Baseline in September 2024 and contrast-color() in April 2026, and newer functions keep arriving; each ships behind the same kind of test, and page 04 shows how.

Parsing is not rendering

@supports and CSS.supports() answer one question: can this browser parse the value? They say nothing about the screen. On a laptop whose display shows only sRGB, Chrome 156 answers CSS.supports("color", "color(display-p3 1 0 0)") with true, and so does every other current browser, because they all understand the notation. The color it names still cannot be shown on that screen; it is gamut-mapped to the nearest red the panel can make, by whatever method the browser uses.

To ask about the screen, use the color-gamut media feature, @media (color-gamut: p3) in CSS or matchMedia("(color-gamut: p3)") in a script. Page 07, Displays and environments, teaches it with the dynamic-range feature for HDR. The distinction comes back throughout this section: the browser's abilities, the screen's abilities and the reader's preferences are three separate questions, and each has its own way to ask.

9. Reading colors back

A script that needs an element's color, to draw a chart in the same color, to compute a contrast ratio, or to pass a color to a canvas, usually asks getComputedStyle(). What it gets back depends on the notation, and that is where old code breaks.

The rules come from CSS Color 4's section on serializing colors, and Chrome 156 follows them:

  • Anything written in an sRGB notation (a name, hex, rgb(), hsl() or hwb()) comes back as rgb() or rgba(), "for compatibility", in the legacy form with commas: rgb(196, 30, 58). System colors and currentColor come back the same way, as the color they resolved to.
  • Anything in a newer space keeps its own notation: lab(), lch(), oklab(), oklch() or color(). Lightness becomes a number, so oklch(53% 0.197 19.5) comes back as oklch(0.53 0.197 19.5).
  • Computed colors land in the space they were computed in. color-mix(in srgb, red, blue) comes back as color(srgb 0.5 0 0.5), not as rgb(), and a relative color such as rgb(from red r g b) as color(srgb 1 0 0).
What getComputedStyle(element).color returned in Chrome 156, October 2026
WrittenRead back
rebeccapurplergb(102, 51, 153)
#c41e3a80rgba(196, 30, 58, 0.5)
hsl(350 73% 44%)rgb(194, 30, 58)
Canvasrgb(255, 255, 255) in a light scheme
lch(43% 70 25)lch(43 70 25)
oklch(53% 0.197 19.5 / 50%)oklch(0.53 0.197 19.5 / 0.5)
color(display-p3 0.8 0.1 0.2)color(display-p3 0.8 0.1 0.2)
color-mix(in srgb, red, blue)color(srgb 0.5 0 0.5)
color-mix(in oklch, red, blue)oklch(0.539974 0.285457 326.643)
rgb(from red r g b)color(srgb 1 0 0)

For most of the web's history every color came back as rgb(), so a lot of code was written to expect it: chart libraries, color-picker widgets, contrast checkers and small helpers copied from answers on the web.

function parseRgb(css) { const m = /^rgba?\((\d+), (\d+), (\d+)/.exec(css); return { r: +m[1], g: +m[2], b: +m[3] }; // TypeError when m is null } const accent = getComputedStyle(button).backgroundColor; // "rgb(196, 30, 58)" yesterday; "oklch(0.53 0.197 19.5)" after the redesign parseRgb(accent);

The day a designer's hex tokens become oklch(), that helper throws, or worse, quietly returns wrong numbers. A design change has broken a script. Better habits:

  • Do not parse color strings with a pattern. Treat a color from CSS as an opaque value and hand it back to the platform: assign it to another element's style, to an SVG fill, or to a canvas's fillStyle, all of which accept every notation the browser parses.
  • Let the canvas convert when you need numbers. Paint one pixel with the color on a canvas and read it back with getImageData(): you get 8-bit sRGB channels, mapped into sRGB the way that browser maps colors. This section's demos measure luminance this way.
  • Read the token, not the result. Even element.style.color returns the browser's serialization, oklch(0.53 0.197 19.5); only a custom property keeps the text as written. Called on the root element's computed style, getPropertyValue('--accent') returns an unregistered custom property's text as it was written, oklch(53% 0.197 19.5). A custom property registered with @property and syntax: "<color>" (CSS Values) is a computed color instead, and follows the rules above.
  • If you must convert, do the math or use a library made for it, such as Color.js by Lea Verou and Chris Lilley, whose code is also the clearest place to read how conversions work.

One edge case shows the specification and a browser disagreeing. The legacy comma form cannot write none, so CSS Color 4 says rgb(none 0 0) should serialize as color(srgb none 0 0). Chrome 156 returns rgb(0, 0, 0). It rarely matters, but it is a reminder to test what a browser does rather than assume the spec describes it.

A color's trip from the style sheet to the screen, and where each question is askedFour stages from left to right. Declared: oklch(53% 0.197 19.5), as written. Computed: oklch(0.53 0.197 19.5), which getComputedStyle reads; colors written in sRGB notations are stored as rgb() with commas, newer spaces keep their own notation, and color-mix() and relative colors land in the space they were computed in. Converted: into the screen's color space, such as sRGB or P3. Mapped and drawn: a color outside the screen's gamut is brought inside, then drawn as pixels. Above the first stage, CSS.supports() and @supports ask whether the browser can parse the value. Above the last stage, the color-gamut media feature asks what the screen can show.CSS.supports()can it parse this?(color-gamut: p3)what can the screen show?declaredoklch(53% 0.197 19.5)as writtencomputedoklch(0.53 0.197 19.5)getComputedStyle() reads thisconvertedinto the screen's spacesRGB, P3 …mapped and drawnout of gamut → insidethen pixelsnames, hex, rgb(), hsl(), hwb(): rgb(196, 30, 58)lab(), lch(), oklab(), oklch(), color(): their own notationcolor-mix() and relative colors: the space they were computed in
Scripts read the computed value, not what reaches the screen. CSS.supports() asks about the first stage, color-gamut about the last.

10. Letting the reader pick a color

Some interfaces let the reader choose a color: a theme setting, a drawing tool, a label in a to-do app. The platform has two pieces for this, and they differ sharply in support.

<input type="color">

Form fields introduced the color input: it opens the platform's color picker, and its value is always a color, never empty. Without the newer attributes below, that value is a lowercase seven-character hex string in every browser, and that is what a form submits. Chrome 156, Firefox 143 and Safari 18.4 accept any CSS color assigned to value and convert it. In Chrome, input.value = "oklch(53% 0.197 19.5)" reads back as #c41d3a, one unit of rounding away from the original hex, and a string that is not a color reads back as #000000, the default.

The HTML standard has since added two attributes. alpha lets the reader choose transparency, and colorspace="display-p3" lets the picker offer wide-gamut colors and report them as color(display-p3 …) rather than hex. As of October 2026 only Safari, from version 18.4, ships them; MDN's data lists Firefox's as a preview, and Chrome 156 ignores both. Browsers that do not know the attributes simply drop them, so adding them is safe, but a form that needs transparency cannot rely on them yet.

<label for="tag-color">Tag color</label> <input id="tag-color" name="tag-color" type="color" value="#c41e3a" alpha colorspace="display-p3">

The color input is only a picker. A reader who knows the color they want, a brand value from a style guide for example, needs a text field too, and the picker itself varies widely: Firefox for Android offers only a set of preset colors, which is why the input is not Baseline. Give the input a visible label, and never let the chosen color be the only sign of what it means.

The EyeDropper API

The EyeDropper API lets the reader pick a color from anywhere on the screen, inside the browser or outside it. It exists only in desktop Chromium browsers: Chrome and Edge 95 and later, secure contexts only from 96, ChromeOS from 120, and not on Linux under Wayland or in Chrome for Android. Firefox and Safari have not implemented it. So it is a textbook case for detecting a feature by its presence and showing the control only where it works:

const button = document.querySelector('#pick-from-screen'); if ('EyeDropper' in window) { button.hidden = false; button.addEventListener('click', async () => { try { const { sRGBHex } = await new EyeDropper().open(); colorInput.value = sRGBHex; // "#aabbcc" } catch { // AbortError: the reader pressed Escape. Nothing to do. } }); }

The design of the API is a lesson in privacy. A page cannot read the screen; open() works only in response to a click or a key press (a "transient user activation"), the browser draws its own magnifier, and the page receives one value, the color of the single pixel the reader chose, and only if they chose one. Pressing Escape rejects the promise with an AbortError, and an AbortSignal passed to open() lets the page cancel. The result is always sRGB hex, even on a P3 screen. Never hide functions behind the button that a reader without it cannot reach: the text field and the color input remain.

11. Hex in, tokens out

Designers will keep handing over hex. Design tools export it, brand guides print it, and it is the one notation everyone recognizes. The developer's job is to turn it into something a style sheet can maintain, once, at the border between design and code:

  1. Convert each hex value to oklch() with a script, not by eye, so the color does not change.
  2. Keep the original hex in a comment beside it, so anyone can trace a token back to the design file.
  3. Name colors by role, such as --surface, --text or --accent, on top of primitives named by hue and step. Components use roles; only the role tokens use primitives. CSS Values introduced custom properties as tokens, and page 05, Choosing and pairing, builds a token set.
  4. Test the result: that every color converted, that it still matches its hex, and that every text pair still meets its contrast.

This site's theme, as a worked example

This site's own colors went through exactly those steps on 27 October 2026. The theme style sheet, src/assets/css/powell-theme.css, defines primitives (navy, gold and gray scales) and role tokens built from them with light-dark(), which page 06, Light and dark, explains. Before, every primitive was hex:

--navy-950: #0B1628; --navy-900: #111F38; --navy-800: #182B49; --paper: #FAFAF8; --page-bg-color: light-dark(var(--paper), var(--navy-950)); --color-surface: light-dark(#FFFFFF, var(--navy-900)); --navy-950: oklch(20.04% 0.0397 259.82) /* #0B1628 */; --navy-900: oklch(24.12% 0.0519 261.22) /* #111F38 */; --navy-800: oklch(28.95% 0.0606 259.19) /* #182B49 */; --paper: oklch(98.45% 0.0026 106.45) /* #FAFAF8 */; --page-bg-color: light-dark(var(--paper), var(--navy-950)); --color-surface: light-dark(oklch(100% 0 0) /* #FFFFFF */, var(--navy-900));

The conversion is a short script, scripts/color/hex-to-oklch.mjs. It splits the style sheet on comments, so hex values already in a comment are left alone; finds every three- or six-digit hex value in the code; converts it with scripts/color/oklch.mjs, which implements Björn Ottosson's published Oklab matrices in about forty lines; and writes oklch(L% C H) /* #HEX */ in its place. It converted the theme's 64 colors. The rounding (two decimals of lightness as a percentage, four of chroma, two of hue) is chosen so that each value converts back to its original hex within one unit per channel.

Hex in, tokens out: how this site's theme was converted and checkedFrom the designer: the hex value #0B1628. An arrow to the script hex-to-oklch.mjs, which uses Ottosson's Oklab matrices. An arrow to the primitive token: --navy-950: oklch(20.04% 0.0397 259.82), with the comment #0B1628. An arrow down to the role token: --page-bg-color: light-dark(var(--paper), var(--navy-950)). A dashed arrow from the tokens to the test, test/theme-contrast.test.js, which converts each oklch() value back to hex within one unit per channel and computes contrast from the oklch() values on every test run. A note at the bottom: screenshots of three pages in light and dark were pixel-identical before and after.from the designer#0B1628hex-to-oklch.mjsOttosson's matricesprimitive token--navy-950: oklch(20.04% 0.0397 259.82)/* #0B1628 */role token--page-bg-color:light-dark(var(--paper), var(--navy-950))test/theme-contrast.test.jsback to hex within one unit per channelcontrast computed from the oklch() valuesBefore and after: screenshots of three pages, light and dark, pixel-identical.
A designer's hex becomes a primitive with its hex in a comment, roles are built on primitives, and a test checks both on every run.

Then the checks. test/theme-contrast.test.js runs with the rest of the site's tests and asserts three things: no hex value is left outside a comment and every oklch() carries its original hex; every oklch() value converts back to that hex within one unit per channel; and the contrast ratios the theme depends on, such as the accent text color on code-block backgrounds at 4.5:1 or more, are computed from the oklch() values, not from the comments. Finally, screenshots of three pages, in light and dark, were compared before and after the conversion and were identical to the pixel. The theme changed notation and nothing else.

What converting shows

A conversion that changes nothing visible still teaches something, because the new numbers are readable. The navy scale's lightness, from --navy-950 to --navy-50, runs 20.0, 24.1, 29.0, 34.6, 41.7, 51.0, then jumps to 73.8, 91.5 and 95.8. In hex that gap between --navy-500 and --navy-300 was invisible; in oklch() it is obvious that the scale has no 400 and that its steps are uneven. The gold scale's hue runs from 98 at --gold-50 through about 91 in the middle to 80 at --gold-800, so the darkest gold leans toward orange. Neither is a bug, since these were the colors the design chose. But the next person to add a step can now see the pattern and fit into it, or decide on purpose to break it.

Strengths and trade-offs

  • Traceable. Every value points back to the design system's hex, so a designer and a developer can talk about the same color in their own notations.
  • Tested from the source of truth. Contrast is computed from the values the browser uses, so editing a token cannot silently break a ratio.
  • The comment can go stale. If someone edits an oklch() value by hand, its hex comment is wrong. The round-trip test catches that and fails, which forces the comment to be updated or deliberately removed.
  • Converted, not designed. The values are still the old palette, with its uneven steps. A palette designed in OKLCH would choose its lightness steps and chroma on purpose, which is the subject of page 05.
  • Precision is spent on fidelity. oklch(20.04% 0.0397 259.82) is more digits than a person would write. They are there so the round trip is exact; a hand-designed token would read oklch(20% 0.04 260).

The broken store's hover states

The opposite of this process is the broken store's SIN #2. On the discolored Trail report, the brand orange is #b54f17, and its hover and active colors were picked by eye, one hex value at a time. The hover, #d9665b, was meant to be a bit lighter and went pink: oklch(64.69% 0.1467 27.41), its hue turned 17 degrees from the brand's 44.6, and white text on it measures 3.50:1, which fails AA. The active color, #8a4a2a, kept the hue and went brown: oklch(48.04% 0.098 45.98), a third of the chroma gone. Rebrand the orange and both stay behind.

The fixed Trail report does this section's conversion on the store: --accent: oklch(55.14% 0.1471 44.6) is exactly #b54f17. The states are plain oklch() values first, then, inside @supports, color-mix(in oklch, var(--accent), var(--ink) 15%) for hover and 30% for active, where --ink is black in light mode and white in dark, with hue none so the mix keeps the brand's hue. Its source comment explains why it uses @supports rather than two declarations in a row: the var() trap of section 8. Deriving is the subject of page 04, Deriving color.

12. Summary

Color in CSS, in brief
ConceptKey points
One typeEvery notation is a <color>. The notation decides the space, how readable and adjustable the value is, and which colors it can reach.
Three generationsKeywords and hex; the sRGB functions rgb(), hsl(), hwb(); and the device-independent lab(), lch(), oklab(), oklch(), color(), Baseline since May 2023 and widely available since November 2025.
Special keywordscurrentColor, transparent, and system colors such as Canvas and CanvasText, which follow the reader's scheme and forced colors.
Modern syntaxSpaces between channels, a slash before alpha, numbers or percentages, angles for hue, none for a missing channel. rgba() and hsla() are aliases.
oklch()Lightness, chroma, hue. Equal L looks close to equally light across hues; HSL's lightness does not (yellow at 50% has thirteen times blue's luminance). Hue angles differ from HSL's.
Out of gamutoklch() can name colors no screen shows. The spec asks for chroma reduction at constant lightness and hue; Chrome 156 clips channels, which shifts lightness and hue. Keep chroma moderate for colors that must match.
Where color appliesText, backgrounds, borders, outlines, decorations, shadows, carets, form controls, scrollbars, SVG and pseudo-elements. color is inherited and defines currentColor, the default for most of the rest.
currentColorIcons, borders and focus rings that follow the text survive themes, hover states and forced colors.
Asking firstA fallback declaration first, then the newer value; or @supports and CSS.supports(). They test parsing, not rendering: ask color-gamut about the screen.
Reading backgetComputedStyle() returns rgb() with commas for sRGB notations, and newer spaces in their own notation, so code that parses rgb( breaks. Hand colors back to the platform, read tokens, or use a canvas.
Pickers<input type="color"> reports lowercase hex; alpha and colorspace are Safari-only so far. The EyeDropper API is desktop Chromium only: detect it by presence, open it from a gesture.
Hex in, tokens outConvert a designer's hex once into oklch() with a script, keep the hex in a comment, name by role, and test the round trip and the contrast. This site's theme did it with no visible change.