Skip to content

notes 6 min css

note

How to get a website's color palette, and its tokens

A website's palette lives in two places: the custom properties its stylesheet declares, usually on :root, and the computed colours its elements actually paint. Read the first for the intended system, the second for the real one - and expect them to disagree.

The fastest manual route is two DevTools views. Select the <html> element and read the custom properties in the Styles pane - on a site with a design system, the palette is sitting there with names. Then open Chrome's CSS Overview panel (More tools, then CSS overview, then Capture overview) for every colour the page paints, grouped into background, text, fill and border colours.

Those two lists are the answer. The rest of this note is about reading them correctly, because the most frequent colour on a page is almost never its brand colour.

Where are a site's colours declared? #

Most sites built in the last few years keep their palette in custom properties, and most put them on :root. The console can read the values one by one, or list every one the page's own stylesheets declare:

every custom property declared on :root, with its resolved value
const root = getComputedStyle(document.documentElement);

[...document.styleSheets]
  .flatMap((sheet) => {
    try { return [...sheet.cssRules]; } catch { return []; }
  })
  .filter((rule) => rule.selectorText === ':root')
  .flatMap((rule) => [...rule.style])
  .filter((name) => name.startsWith('--'))
  .map((name) => [name, root.getPropertyValue(name).trim()]);
  • The try is not decoration. A stylesheet from another origin without CORS headers throws a SecurityError when its rules are read, so a site serving its CSS from a CDN can hide half its tokens from this snippet.
  • Rules inside @media or @layer blocks are nested and are not in the top-level list. A dark theme declared under prefers-color-scheme needs one more level of walking.
  • Names are not types. --brand-2 may be a colour or a spacing step; the value is the only thing that says which.

Why do the declared colours differ from the painted ones? #

Because a token file describes intent and a page accumulates exceptions. A colour typed directly into one component, a third-party widget with its own palette, a token defined and never used - all of these make the declared list and the painted list part company.

Declared, never painted
A token left over from an old theme, or a scale step nobody chose. It is in the system and not on the page.
Painted, never declared
A literal value in one component. It is on the page and outside the system, which is exactly what an audit is looking for.
Painted through alpha
rgb(0 0 0 / 0.6) over white is a grey nobody declared. The computed value keeps the alpha; what the eye sees is the composite.
Painted only in another state
Hover, focus, an open menu, dark mode. A single reading of the page sees none of them.

getComputedStyle reports the painted value per element, usually as rgb() or rgba(); colours authored in oklch() or color() are generally reported in that space instead. Either way it is a value per element, not a palette - grouping is the next job.

Which colour is the brand colour? #

Not the most frequent one. Count colours by how many elements use them and the top of the list is white, a near-black for text and three greys for borders and secondary copy. The brand colour is usually used on few elements and in prominent places - the primary button, links, the logo, a highlight.

  1. Set the neutrals aside: anything with very low chroma is a grey, whatever its name.
  2. Among what is left, look for the colour on the main call to action and on links. That is nearly always the brand or primary colour.
  3. Look for semantic colours next - a red on error text, a green on success states. They are part of the palette and are not the brand.
  4. Merge near-duplicates. Two blues a fraction apart are usually one token and one mistake, not two colours.

Near-duplicates are the most useful finding in a palette audit. Two colours you cannot tell apart on screen are a system that drifted, and they are invisible in a list sorted by name.

What about gradients, SVG and images? #

Gradients
They live in background-image, not background-color, so a reading of background colours misses them entirely. The stops are in the computed background-image string.
Inline SVG
Icons and illustrations paint with fill and stroke. An icon set using currentColor inherits the text colour and adds nothing new; one with literal fills is a second palette.
Raster images
A hero photograph's colours are pixels, not CSS. No stylesheet reading finds them. This is the one place an eyedropper is the right tool.
Canvas and video
The same as images: painted, never declared.

For pixels, DevTools has an eyedropper inside the colour picker in the Styles pane, and Firefox has one in the Inspector toolbar. Chromium browsers also expose the EyeDropper API, which samples any pixel on screen and returns it as an sRGB value.

How do you check the dark theme? #

Emulate it rather than switching your system setting. In Chrome, open the Rendering drawer and set Emulate CSS media feature prefers-color-scheme to dark; Firefox has a dark-scheme toggle in the Inspector's rules pane. Then read the custom properties again: a well-built theme changes the values of the same names, and a theme that introduces new names is two systems sharing a stylesheet.

How do you export the palette as tokens or Tailwind? #

Once the colours are grouped and named, the format depends on where they are going. Tailwind 4 reads its theme from CSS, in the --color- namespace, and generates the utilities from it:

Tailwind 4: each variable becomes bg-, text-, border- utilities
@theme {
  --color-brand-500: oklch(0.62 0.19 255);
  --color-brand-600: oklch(0.54 0.19 255);
  --color-ink: oklch(0.21 0.02 260);
  --color-danger: oklch(0.58 0.22 27);
}
Tailwind 4
An @theme block in CSS, as above. The class bg-brand-500 exists the moment the variable does.
Tailwind 3
theme.extend.colors in tailwind.config.js, as a nested object.
Plain CSS
Custom properties on :root, with the dark values redeclared under a media query or a theme attribute.
Design tools
The Design Tokens Community Group JSON format, which Figma plugins and Style Dictionary read.

Recovering a full Tailwind theme from a built site - spacing, radii and type as well as colour - is its own problem, covered in the note on recovering a Tailwind theme. And before anything is exported, the text colours deserve a contrast check against the backgrounds they actually sit on; the note on contrast on real backgrounds explains why the swatch pair is not enough.

How LoupeKit reads both lists at once #

The UI tab's Extract view walks the page once and returns every colour it paints, ranked by how many elements use each, given a role - brand, accent, neutral or semantic - and a suggested token name. Choosing a colour lists every element that uses it and through which property: text, background, border, fill or stroke. A picker samples any pixel, including the ones inside images.

  • Tokens reads the custom properties in scope, resolved, decides what each one is from its value rather than its name, and lets you change one live to see everything it controls.
  • Tailwind theme reads what a built page paints and writes it out as a Tailwind theme, in the @theme form or the older config shape.
  • Palette turns one colour into a full OKLCH scale with a contrast matrix, and exports it as Tailwind 4, CSS, Style Dictionary or Figma variables.

None of it is sent anywhere: the reading happens in the page, and the export is a file you save.

Written by Ján Turský

Building LoupeKit and other browser tools out of Bratislava, under Apptiary.

run it here, no install

tools in this note

SAVE 33% €74.99 €49.99 Lifetime See the offer