The fastest way to extract design tokens from a website in Python is Playwright plus getComputedStyle(): load the page in a real browser, collect the computed styles of every element, and count what repeats. The counting is the important part. A site's design system is whatever it uses everywhere, not every value that appears once in a forgotten footer.
Parsing CSS files without a browser is the approach most tutorials suggest, and it is the one that works worst on modern sites. More on that below.
Extract colors with Playwright
First, install Playwright and a browser:
pip install playwright
playwright install chromium
Then collect styles from every element and count them:
from collections import Counter
from playwright.sync_api import sync_playwright
COLLECT = """
() => {
const els = [...document.querySelectorAll('body *')].slice(0, 5000);
const out = { colors: [], fonts: [], sizes: [], radii: [], shadows: [] };
for (const el of els) {
const s = getComputedStyle(el);
out.colors.push(s.color, s.backgroundColor, s.borderTopColor);
out.fonts.push(s.fontFamily);
out.sizes.push(s.fontSize);
out.radii.push(s.borderTopLeftRadius);
out.shadows.push(s.boxShadow);
}
return out;
}
"""
def top(values, n=8, skip=("", "none", "normal", "auto", "0px", "rgba(0, 0, 0, 0)")):
return Counter(v for v in values if v not in skip).most_common(n)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.goto("https://stripe.com", wait_until="networkidle")
raw = page.evaluate(COLLECT)
browser.close()
for name in ("colors", "fonts", "sizes", "radii", "shadows"):
print(f"\n== {name} ==")
for value, count in top(raw[name]):
print(f"{count:>5} {value}")
Run it and you get something like:
== colors ==
3021 rgb(255, 255, 255)
1204 rgb(26, 27, 37)
412 rgb(99, 91, 255)
118 rgb(66, 84, 102)
That third line is the brand color, found by nothing smarter than counting. getComputedStyle has already done the hard work for you: it resolved every CSS variable chain, every var(--a, var(--b)) fallback, every cascade conflict, and turned Tailwind utility soup into plain rgb() values.
Fonts and the type scale
The same collection already has them. Font families come back as full stacks, so take the first entry:
families = Counter(f.split(",")[0].strip('"\' ') for f in raw["fonts"])
print(families.most_common(5))
sizes = sorted(
{float(s.replace("px", "")) for s, c in top(raw["sizes"], n=20)},
)
print(sizes) # e.g. [12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 36.0, 56.0]
That sorted list is the site's type scale. If it has fifteen entries instead of seven, you are looking at a site without a design system, which is also useful to know.
From raw values to actual tokens
Raw counts are not tokens yet. Three cleanup steps matter:
- Drop the defaults.
rgb(0, 0, 0)near the top does not mean the brand is black. Browser resets and inherited defaults pollute every element, so check whether a value actually appears on visible, styled elements before promoting it. - Merge near-duplicates.
rgb(99, 91, 255)andrgb(99, 92, 255)are the same token with a rounding disagreement. Snap channels to a small tolerance before counting, or you will split one token's count across five entries. - Name by role, not by value. The most frequent saturated color on buttons and links is
primary. The dominant text color istext. Frequency plus element context gets you there; k-means clustering is overkill for this and I would skip it.
Common problems
Real gotchas, in the order they will bite you:
- Your viewport is a filter. Computed styles reflect the current viewport only. A
@media (max-width: 640px)override is invisible at 1440px. Extract at two viewports (mobile and desktop) if you care about responsive tokens. - Cookie banners poison the palette. Consent overlays and chat widgets bring their own colors and fonts, and they are often thousands of DOM nodes. Dismiss them or exclude their containers before collecting.
- Fonts can lie.
font-familylists what CSS asked for, not what loaded. Withfont-display: swapthe brand font may never arrive in a headless run. Awaitdocument.fonts.readybefore collecting if exact loaded fonts matter. - Gradients are not in
backgroundColor. They live inbackgroundImage. If a brand is gradient-heavy, collect that property too or you will report their hero as plain white. - Shadow DOM and iframes are invisible to
querySelectorAll. Web-component-heavy sites need you to walkshadowRoots explicitly.
DIY vs parsing CSS vs an API
| Approach | Tailwind / utility CSS | Runtime CSS-in-JS | Resolves var() and cascade | Media queries | Maintenance |
|---|---|---|---|---|---|
| Playwright + computed styles (this guide) | Yes | Yes | Yes | Current viewport only | Browser infra is yours to run |
| Parse CSS files yourself (tinycss2, no browser) | Painful | No | No | Declared, but unused values included | Low, but wrong on modern sites |
| MiroMiro extract API | Yes | No (static HTML + CSS, no JS execution) | Yes | Yes, all declared breakpoints | None |
Use the Playwright approach if you are extracting from a handful of sites, need runtime-injected styles, and want full control. Skip the hand-rolled CSS-parsing route: resolving the cascade, specificity, and nested var() fallbacks yourself is a project, not a script.
Or use one API call
The dedupe, the ranking, the var() chains, the resets, and every declared breakpoint - that is what our extract endpoint does for a living:
curl "https://miromiro.app/api/v1/extract?url=stripe.com" \
-H "Authorization: Bearer $MIROMIRO_API_KEY"
The JSON response has colors, fontFamilies, fontSizes, fontWeights, spacing, borderRadiuses, boxShadows, and more, already deduped and ranked. Trim it with ?fields=colors,spacing if you only need part of it.
If your script from this guide breaks on some site's cookie banner at 2am, that is roughly the moment an API earns its keep. You can get a free API key with 100 credits a month, no card, or try it without signing up in the playground.
Summary
Playwright plus getComputedStyle plus a Counter is the honest way to extract design tokens in Python, and the frequency counting matters more than any clever clustering. Parse-the-CSS approaches are not worth your time on modern sites. I hope this saved you a debugging evening.
Frequently asked questions
Can I extract design tokens without a headless browser?
You can parse the site's CSS with a library like tinycss2, but on modern sites this works badly: CSS-in-JS generates styles at runtime, Tailwind buries the palette in utility classes, and you cannot tell which declared values are actually used. Computed styles from a real browser are the ground truth.
How do I extract tokens from a Tailwind site where everything is utility classes?
Ignore the class names entirely and read computed styles. getComputedStyle resolves bg-indigo-600 to rgb(79, 70, 229) like any other CSS, so nothing in this guide changes.
Why do my extracted colors include values the site never visibly uses?
Three usual suspects: browser defaults on elements you collected, cookie banners and chat widgets injecting their own palette, and transparent or inherited values you forgot to filter.
How many pages should I sample?
One landing page is usually enough for brand color and typography. Add one product or app page for functional colors (success, error, info). More than three pages rarely changes the top tokens.
Related reading
- Design tokens API - the endpoint this post's shortcut uses
- Design tokens docs - full response shape and field list
- Ground truth for AI agents - why agents need real tokens instead of guessing from screenshots
Skip the maintenance - one API call
Design tokens, brand data, fonts, SVGs, images, and section code from any URL. 100 free credits a month, no card.
Soraia · Founder, MiroMiro
Building MiroMiro: a browser extension and API that extract design tokens, assets, and production-ready code from any live website. The gotchas in these posts come from the extraction engine's own commit history.
Follow on X