← blog

Extracting design tokens (colors, fonts, spacing) from any website

Technique · August 13, 2026 · 12 min read

Console snippets that pull the real type ramp, spacing scale, and custom properties out of a live page, the traps that make naive extraction wrong, and when to stop doing it by hand.

You can read a site’s design decisions directly out of the browser. Not approximately, by eyedropping a screenshot, but exactly, because the browser computed those values in order to paint the page and will hand them to you if you ask.

This post is the by-hand version: paste-into-the-console snippets that get you most of the way, plus the traps that make naive extraction produce numbers that look right and are wrong.

Start with the custom properties, if there are any

Modern sites increasingly declare their tokens as CSS custom properties, which means the design system is sitting there already named. Always check this first; it can make the rest of the work unnecessary.

const names = new Set();
for (const sheet of document.styleSheets) {
  let rules;
  try {
    rules = sheet.cssRules;
  } catch {
    continue; // cross-origin stylesheet, not readable
  }
  for (const rule of rules) {
    if (!rule.style) continue;
    for (const prop of rule.style) {
      if (prop.startsWith('--')) names.add(prop);
    }
  }
}

const root = getComputedStyle(document.documentElement);
console.table(
  [...names]
    .sort()
    .map((name) => ({ name, value: root.getPropertyValue(name).trim() }))
);

The try/catch is not defensive padding. A stylesheet served from another origin without CORS headers throws a SecurityError on `cssRules` access, and on a real site several of them will be. Without the catch, the loop dies on the first font provider stylesheet and you conclude the site has no tokens.

Also note the values come back as authored, so a variable may resolve to another variable. Read it off `getComputedStyle(document.documentElement)` as above and you get the resolved value.

The type ramp

What you want is the distinct combinations of family, weight, size, line height, and tracking that are actually used on text that exists, ranked by how much of the page uses them.

const ramp = new Map();

for (const el of document.querySelectorAll('*')) {
  // Only elements with their own text; a wrapper inherits and would double-count.
  const ownText = [...el.childNodes]
    .filter((n) => n.nodeType === Node.TEXT_NODE)
    .map((n) => n.textContent.trim())
    .join('');
  if (!ownText) continue;

  const s = getComputedStyle(el);
  const key = [
    s.fontFamily.split(',')[0].replace(/["']/g, ''),
    s.fontWeight,
    s.fontSize,
    s.lineHeight,
    s.letterSpacing,
  ].join(' | ');

  const entry = ramp.get(key) ?? { key, elements: 0, chars: 0 };
  entry.elements += 1;
  entry.chars += ownText.length;
  ramp.set(key, entry);
}

console.table([...ramp.values()].sort((a, b) => b.chars - a.chars));

Ranking by character count rather than element count is the detail that makes this useful. Element count tells you that the site has a lot of tiny labels; character count tells you what the body style is, which is the one you actually want.

Two gotchas. `lineHeight` comes back as `normal` when it was never set, which is not a number and varies by font; treat it as unset rather than converting it. And `fontFamily` gives you the whole stack, so take the first entry but keep the rest if you care about the fallback chain.

The spacing scale

Spacing is where by-eye estimation fails worst, and where the extracted answer is most satisfying, because a real scale jumps out of the histogram immediately.

const PROPS = [
  'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
  'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
  'rowGap', 'columnGap',
];

const counts = new Map();
for (const el of document.querySelectorAll('*')) {
  const s = getComputedStyle(el);
  for (const prop of PROPS) {
    const v = Number.parseFloat(s[prop]);
    if (Number.isFinite(v) && v > 0) counts.set(v, (counts.get(v) ?? 0) + 1);
  }
}

console.table(
  [...counts.entries()]
    .map(([px, uses]) => ({ px, uses }))
    .sort((a, b) => b.uses - a.uses)
    .slice(0, 24)
);

Read the output as a distribution, not as a list. A site on a 4px scale shows a sharp spike at 4, 8, 12, 16, 24, 32, 48 with a long tail of one-off values. The tail is noise; the spikes are the scale. If there is no visible spike pattern, the site does not have a spacing scale, which is itself worth knowing before you spend an afternoon trying to reverse-engineer one.

Note that computed spacing is always resolved to pixels, so a value authored as `1.5rem` arrives as `24px`. That is what you want for reading the scale, and it is worth remembering when you go to write it back out.

Colors, and the trap that ruins them

Collecting colors is the easy part: walk the elements, read `color`, `backgroundColor`, `borderColor`, and count.

The trap is translucency, and it invalidates nearly every naive color extraction and every eyedropper.

A surface painted as `rgba(255,255,255,0.06)` over a `#0B0B0F` page renders as roughly `#1A1A1E`. An eyedropper on a screenshot gives you `#1A1A1E`. `getComputedStyle` correctly gives you `rgba(255, 255, 255, 0.06)`, which is the truth. If you flatten it to `#1A1A1E` and use that, you have hard-coded one particular background into a surface that was designed to work over any of them, and it will look wrong the first time it sits somewhere else.

So: keep the alpha. When you record a color, record it as authored, and record what it was sitting on separately. A token list of flattened hex values is a token list that has already lost the information you extracted it to get.

The second trap is `currentColor` and inheritance. A border declared as `1px solid currentColor` computes to whatever the text color is at that element, so the same declaration produces different computed values in different places. Counting them as distinct colors invents variety that is not in the design.

Radii, borders, and shadows

Same walk, different properties, and all three are worth doing because they are the details people get wrong when copying by eye.

For radii, collect `borderRadius` and expect a much smaller set than you think: most sites use three or four values total. For borders, collect the full `border` shorthand rather than just the color, because the interesting thing is usually that it is a hairline at low alpha rather than a solid line.

Shadows are the highest-value extraction on this list, because a good shadow is two or three layered shadows with different blurs and spreads, and nobody reproduces that by eye. `getComputedStyle(el).boxShadow` gives you the whole stack, comma separated, exactly as it will be applied.

Motion

Durations and easings are in the computed styles and are almost never recovered from screenshots, because a screenshot has no time in it.

Read `transitionProperty`, `transitionDuration`, `transitionTimingFunction`, and the animation equivalents. A cubic-bezier that appears repeatedly across a site is that site’s motion signature, and it is one of the highest-leverage things to steal because it costs nothing to apply and does a lot of work.

The catch: transitions on hover and focus states are only computed when the state is active. To read them you need to force the state, which in the console means using `:hover` via devtools rather than a script. This is one of the places where by-hand extraction is genuinely awkward and tooling helps.

When to stop doing this by hand

The snippets above are worth having and take a minute to run. The limits show up quickly.

  • You cannot easily read state styles (hover, focus, disabled) without forcing them one at a time.
  • You are looking at one viewport. The breakpoint behaviour is in media queries you would have to parse separately.
  • Nothing here is saved. Run it, read it, close the tab, and it is gone. Doing this on twelve sites to compare them means twelve manual passes and a text file.
  • Clustering is left to your eye. Turning 16 near-identical greys into 5 tokens is exactly the kind of thing that should be computed with a perceptual color distance rather than judged.

That last one matters more than it sounds. Perceptual distance in a uniform color space (deltaE, or comparing in OKLCH) is what separates "these are the same token" from "these are two decisions", and by eye on a screen you will merge things you should not and split things you should.

The tooled version

Stele captures the element from the live page and extracts all of the above as part of the capture: colors with assigned roles and a confidence score, the type ramp, spacing, radii, borders, shadows, gradients, breakpoints, and motion, with near-identical colors clustered by perceptual distance rather than by eye. It also runs a WCAG and APCA contrast audit over the pairings the capture actually uses, which is the check people most often skip when copying a palette.

If you just want the color half without installing anything, we run a free palette extractor at /tools/palette-extractor that pulls dominant colors out of an image entirely in your browser, and a contrast checker at /tools/contrast-checker.

Either way, the point stands: the values are there, and reading them beats guessing at them.