You can read a site’s design decisions straight out of the browser. Not approximately, the way you would eyedropping a screenshot, but exactly, because the browser already computed those values to paint the page in front of you, and it’ll hand them over if you just ask.
This is the by hand version of that ask. Paste these into the console and they’ll get you most of the way there, plus the traps that make naive extraction spit out numbers that look right and aren’t.
Start with the custom properties, if there are any
A lot of modern sites declare their tokens as CSS custom properties, which means the design system is basically just sitting there, already named. Always check this first. It can make everything else on this page 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() }))
);That try/catch isn’t just defensive padding for the sake of it. A stylesheet served from another origin without CORS headers throws a SecurityError the moment you touch `cssRules`, and on a real site a few of them always do. Skip the catch and the loop dies on the first font provider stylesheet it hits, and you’ll walk away thinking the site has no tokens at all.
Also worth knowing: the values come back exactly as authored, so a variable can resolve to another variable. Read it off `getComputedStyle(document.documentElement)` like above and you actually get the resolved value.
The type ramp
What you want here is the distinct combinations of family, weight, size, line height, and tracking that are actually in use on text that exists on the page, ranked by how much of the page relies on 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 instead of element count is the detail that actually makes this useful. Element count just tells you a site has a lot of tiny labels. Character count tells you what the body style really is, which is the thing you actually wanted in the first place.
Two gotchas worth flagging. `lineHeight` comes back as `normal` when it was never explicitly set, and that’s not a number, it varies by font, so treat it as unset rather than trying to convert it into one. And `fontFamily` gives you the whole stack, so grab the first entry but hang onto the rest if the fallback chain matters to you.
The spacing scale
Spacing is where guessing by eye fails the hardest, and where the extracted answer feels the most satisfying, because a real scale just jumps straight out of the histogram.
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 a list. A site built on a 4px scale shows a sharp spike at 4, 8, 12, 16, 24, 32, 48 with a long tail of one off values trailing behind. The tail is noise. The spikes are the scale. And if you don’t see a spike pattern at all, that site just doesn’t have a spacing scale, which is genuinely useful to know before you burn an afternoon trying to reverse engineer one that isn’t there.
One more thing worth remembering: computed spacing always comes back resolved to pixels, so something authored as `1.5rem` shows up as `24px`. That’s exactly what you want for reading the scale, and it’s worth keeping in mind when you go to write the values back out.
Colors, and the trap that ruins them
Collecting colors is the easy part. Walk the elements, read `color`, `backgroundColor`, `borderColor`, and just count them up.
The trap is translucency, and it quietly ruins nearly every naive color extraction, plus every eyedropper you’ll ever run.
A surface painted as `rgba(255,255,255,0.06)` sitting over a `#0B0B0F` page renders out to roughly `#1A1A1E`. An eyedropper on a screenshot will give you `#1A1A1E` and call it a day. `getComputedStyle` correctly gives you `rgba(255, 255, 255, 0.06)`, which is the actual truth. Flatten that down to `#1A1A1E` and use it, and you’ve just baked one specific background into a surface that was designed to sit on top of anything, and it’ll look wrong the very first time it lands somewhere else.
So keep the alpha. When you record a color, record it exactly as authored, and write down separately what it was sitting on. A token list of flattened hex values is a token list that already lost the exact information you extracted it to get in the first place.
The second trap is `currentColor` and inheritance. A border declared as `1px solid currentColor` computes out to whatever the text color happens to be at that specific element, so the same one line of CSS produces different computed values in different places on the page. Count those as distinct colors and you’ll invent variety that was never actually part of the design.
Radii, borders, and shadows
Same walk, different properties, and all three are worth doing because they’re exactly the details people get wrong when they’re copying something by eye.
For radii, collect `borderRadius` and expect a much smaller set than you’d guess. Most sites really only use three or four values total. For borders, grab the full `border` shorthand rather than just the color, because the interesting part is usually that it’s a hairline at low alpha rather than a plain solid line.
Shadows are the highest value extraction on this whole list, because a good shadow is almost always two or three layered shadows with different blurs and spreads, and nobody reproduces that by eye. `getComputedStyle(el).boxShadow` hands you the whole stack, comma separated, exactly as it’ll be applied.
Motion
Durations and easings live in the computed styles, and they’re almost never something you can recover from a screenshot, because a screenshot has no time in it at all.
Read `transitionProperty`, `transitionDuration`, `transitionTimingFunction`, and their animation equivalents. A cubic-bezier that shows up over and over across a site is basically that site’s motion signature, and it’s one of the highest leverage things you can borrow, because it costs nothing to apply and does a lot of work once it’s there.
The catch is that transitions on hover and focus states only get computed while that state is actually active. Reading them means forcing the state, and in the console that means using `:hover` through devtools rather than a script. This is one of the few places where doing this by hand genuinely gets awkward, and where tooling actually earns its keep.
When to stop doing this by hand
The snippets above are worth having, and each one takes about a minute to run. But the limits show up fast.
- You can’t easily read state styles like hover, focus, or disabled without forcing them one at a time.
- You’re only looking at one viewport. The breakpoint behavior lives in media queries you’d have to go parse separately.
- Nothing here is saved anywhere. Run it, read it, close the tab, and it’s gone. Doing this across twelve sites to compare them means twelve manual passes and a text file you’ll lose track of.
- Clustering is left entirely to your eye. Turning sixteen near identical greys into five real tokens is exactly the kind of thing that should be computed with actual perceptual color distance instead of eyeballed.
That last one matters more than it sounds. Perceptual distance in a uniform color space, deltaE or comparing in OKLCH, is what actually separates "these are the same token" from "these are two different decisions," and staring at a screen you will merge things you shouldn’t and split things you should have kept together.
The tooled version
Stele captures the element straight from the live page and extracts everything above as part of that 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 instead of by eye. It also runs a WCAG and APCA contrast audit over the exact pairings the capture actually uses, which is the check people skip most often when they’re copying a palette by hand.
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 already there. Reading them beats guessing at them, every time.