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.

Colorize5 min read
How to Extract Brand Colors From Modern CSS

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 held

The 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:

SourceWhat it gives youWhy it matters
Custom properties on :rootToken names and valuesNames describe a color's job
Computed styles on real elementsColors as used, after everything resolvesThe only trustworthy values
Both color schemesTwo labelled palettesNeither one is the whole answer
Original color spaceoklch, display-p3 and friendsHex 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.

Taggedcssbrand colorscolor theory
Share this articleXLinkedInReddit

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

Sign up for free

Keep reading