Engineering
How to Extract Brand Colors From Modern CSS
Regex over stylesheets used to be a fine way to collect a site's colors. CSS Color 4 and 5 ended that. Here is what to read instead of source text.

For about fifteen years you could collect every color a website used with three regular expressions. One for hex, one for rgb() and rgba(), one for hsl() and hsla(). Between them they matched essentially every color anyone wrote.
That stopped being true. A stylesheet written this year can define its entire palette without producing a single match against any of those patterns:
@property --brand {
syntax: '<color>';
inherits: true;
initial-value: oklch(0.55 0.21 264);
}
:root {
--brand-hover: oklch(from var(--brand) calc(l - 0.08) c h);
--brand-subtle: color-mix(in oklch, var(--brand) 12%, white);
--surface: light-dark(oklch(0.99 0 0), oklch(0.16 0.01 264));
--accent: color(display-p3 0.9 0.35 0.1);
}Five colors, zero hex codes, zero rgb(). A scraper built on pattern matching reports that this site has no colors.
#What actually changed
Three specs landed in browsers in close succession, and each broke a different assumption.
CSS Color 4 added color spaces beyond sRGB. lab(), lch(), oklab(), oklch() and hwb() are all now ordinary things to write, along with the color() function for addressing a specific space such as display-p3 or rec2020. oklch() in particular has become the default choice for design systems, because its lightness axis is perceptually even, which makes generating a tint and shade ramp a matter of stepping one number.
CSS Color 5 added colors that are computed from other colors. color-mix() blends two colors in a named space. Relative color syntax, the oklch(from var(--brand) ...) form, takes an existing color apart into components you can do arithmetic on. Neither has a literal value anywhere in the source.
light-dark() puts two values in one declaration and picks between them based on the resolved color-scheme. The source text contains both. The page shows one.
The through line is that the relationship between what is written in the stylesheet and what appears on screen went from "identical" to "the output of a small program". Pattern matching reads the program's source. You want its result.
#Read computed values, not source text
The browser already contains a complete, correct implementation of all of this. getComputedStyle gives you the answer after cascade, inheritance, var() substitution, relative color resolution, color-mix() evaluation and light-dark() selection have all been applied.
const el = document.querySelector('.cta')
getComputedStyle(el).backgroundColor
// "oklch(0.55 0.21 264)"This is not the trick it sounds like. It is the only way to get a correct answer for a declaration involving var(), because resolving var() yourself means reimplementing the cascade, and the cascade is not a thing you want to reimplement.
#The custom property trap
The obvious next step is to enumerate the custom properties directly, since a design system's palette is usually declared as tokens on :root:
const root = getComputedStyle(document.documentElement)
Array.from(root)
.filter((name) => name.startsWith('--'))
.map((name) => [name, root.getPropertyValue(name).trim()])This works, and it is worth doing, because token names carry meaning that a bare hex code does not. But it has a sharp edge that catches people. An unregistered custom property is substitution only. Its computed value is the token stream you wrote, not a resolved color. So this:
:root {
--brand-subtle: color-mix(in oklch, var(--brand) 12%, white);
}reads back as the literal string color-mix(in oklch, var(--brand) 12%, white). You have moved the problem, not solved it.
Registering the property with @property and syntax: '<color>' changes that: registered properties compute to a real value, and read back resolved. But you do not control whether the site you are reading did that.
The reliable way to resolve an arbitrary token is to make the browser use it as a color and then read what it used. Put it on a throwaway element:
const resolveColor = (value) => {
const probe = document.createElement('div')
probe.style.color = 'rgb(1, 2, 3)' // sentinel: survives an invalid value
probe.style.color = value
document.documentElement.append(probe)
const resolved = getComputedStyle(probe).color
probe.remove()
return resolved === 'rgb(1, 2, 3)' ? null : resolved
}
resolveColor('var(--brand-subtle)') // an actual color, whatever the token heldThe sentinel matters. Assigning an invalid value to a style property is a no-op, so without a known starting value you cannot tell a failed assignment from a successful one that happened to produce the same color.
#Computed values are not all sRGB any more
The old comfortable assumption was that getComputedStyle always hands back rgb(r, g, b). That was true while every color was an sRGB color. It is not true now. A color authored in a wider space serializes in that space, so you will receive strings like:
oklch(0.55 0.21 264)
color(display-p3 0.9 0.35 0.1)
lab(52.2 40.1 59.9)Two consequences follow. First, whatever parses your results needs to handle those forms, which means using a color library that implements CSS Color 4 rather than a hand-rolled rgb() splitter.
Second, and more awkward: some of those colors do not exist in sRGB. A saturated display-p3 red is outside the sRGB gamut, and there is no hex code that represents it. You have to decide what to do, and the two options are not equal.
Clipping each channel into range is what naive code does implicitly and it distorts hue, sometimes badly, because clamping one channel while the others stay put moves the color sideways rather than just desaturating it.
Gamut mapping as described in CSS Color 4 reduces chroma in OKLCH while holding lightness and hue, stepping down until the color fits. It is more work and it produces a color that still looks like the one you started with.
If your output is hex, you are gamut mapping whether you admit it or not. Do it deliberately, and keep the original value alongside the mapped one so the information is not lost.
#Both color schemes are real
light-dark() and prefers-color-scheme mean a page has at least two palettes, and the one you capture depends on what your renderer emulates. Most headless setups default to light and do not mention it.
If you are driving a browser, you can ask for each explicitly and capture twice:
await page.emulateMediaFeatures([
{ name: 'prefers-color-scheme', value: 'dark' },
])Treat the two results as two labelled palettes rather than merging them. A brand's dark surface color is not a variant of its light surface color, it is a separate decision, and flattening them produces a palette containing both #FFFFFF and #0B1220 with no explanation of why.
#What to collect
Given all of the above, a color pass over a live page that holds up in 2026 gathers four things:
| Source | What it gives you | Why it matters |
|---|---|---|
Custom properties on :root | Token names and values | Names describe a color's job |
| Computed styles on real elements | Colors as used, after everything resolves | The only trustworthy values |
| Both color schemes | Two labelled palettes | Neither one is the whole answer |
| Original color space | oklch, display-p3 and friends | Hex silently discards this |
Notice that "the text of the stylesheet" is not on the list. It is still worth reading for one narrow purpose, which is discovering token names that are declared but not currently in scope, such as a dark-only or logged-in-only theme block. As a source of color values it has been superseded.
Worth saying plainly: a color declared in CSS is still only part of a brand's palette. The colors in a logo are not in the stylesheet at all, and reading those means working from the image, which is a different problem with a different set of traps. How to Extract Brand Colors From SVG Logos covers that side, and How to Extract Brand Colors From Any Website covers when to reach for which.
Do you want API access?
Access our API to integrate color, brand & screenshot extraction into your app:
Brand Logos
Site Assets & Screenshot
Color Extraction & Grouping
Categorization & Company Details


