Every browser already knows the full computed style of every element on a page. Margins, colors, font stacks, spacing rhythms — the rendering engine has resolved all of it before the first pixel is painted. Design Catcher's extraction pipeline starts from that fact and works backward: if the browser has already done the hard work, we just need to read what it knows.
This post walks through how that extraction works in detail — the two phases, the JavaScript primitives we rely on, what we can and cannot capture, and why we deliberately separate machine facts from LLM prose.
Why not just parse the stylesheets?
The naive approach to extracting design tokens is to fetch the site's CSS files, run a regex over them, and pull out hex values and font names. That works poorly in 2026 for three reasons.
First, a growing share of styling never appears in a .css file. CSS-in-JS libraries (Styled
Components, Emotion, vanilla-extract) generate class names at runtime. Tailwind utility classes
are atomic — text-blue-600 by itself tells you nothing about whether blue is the
brand color or just an inline link style. Server-side rendering pre-renders the HTML with
inlined styles, but the stylesheet itself may be minimal or empty.
Second, stylesheet parsing gives you the authored value, not the resolved
value. A color defined as hsl(220 90% 55% / 1) in source may appear as
rgb(36, 99, 235) in the browser. A font stack of
Inter, "Helvetica Neue", sans-serif may resolve to a different first-match depending
on the user's OS. Computed styles always give you what the browser actually uses.
Third, @import chains and cross-origin stylesheets are opaque to JavaScript.
Attempting to read document.styleSheets across origins throws a security error.
Computed styles bypass this entirely — the cascade has already been applied, and the result is
fully readable through the DOM API.
Phase 1: deterministic extraction
The first phase of Design Catcher's pipeline is deterministic. Given the same page, it always produces the same output. No AI, no interpretation, no ambiguity — just fact collection.
Reading CSS custom properties
Most modern design systems store their tokens as CSS custom properties on :root or
a theme wrapper. Reading them is straightforward:
// Enumerate all CSS custom properties declared on :root
function getRootCustomProperties() {
const styles = getComputedStyle(document.documentElement);
const properties = {};
// CSSStyleDeclaration doesn't expose a clean list of custom properties,
// so we iterate all style sheets to collect the property names,
// then resolve their computed values from the root element.
for (const sheet of document.styleSheets) {
let rules;
try {
rules = sheet.cssRules;
} catch {
// Cross-origin sheet — skip
continue;
}
for (const rule of rules) {
if (rule.type !== CSSRule.STYLE_RULE) continue;
for (const prop of rule.style) {
if (prop.startsWith("--")) {
// Resolve computed value from root, not authored value from rule
const computed = styles.getPropertyValue(prop).trim();
if (computed) properties[prop] = computed;
}
}
}
}
return properties;
}
This gives us a flat map of every custom property and its resolved value. For a typical SaaS
product, this map is 40–120 entries: --color-surface: #FAFAF8,
--space-4: 1rem, --radius-md: 8px. These are already close to design
tokens in spirit; we just haven't named them semantically yet.
Reading computed styles from representative elements
Custom properties are only part of the picture. Many values are never stored in custom
properties — they're set directly as declarations or applied through framework utilities.
For these, we use window.getComputedStyle() on a set of representative elements:
// Selectors for representative elements — covers typography, layout, and interactive components
const REPRESENTATIVE_SELECTORS = [
"body",
"h1", "h2", "h3",
"p",
"a",
"button, [role='button']",
"input, textarea, select",
"[class*='nav'], [role='navigation']",
"header",
"main",
"footer",
];
// Properties we care about extracting
const TRACKED_PROPERTIES = [
"color",
"background-color",
"font-family",
"font-size",
"font-weight",
"line-height",
"letter-spacing",
"border-radius",
"padding",
"margin",
"gap",
"box-shadow",
"border-color",
"border-width",
];
function extractComputedStyles() {
const results = [];
for (const selector of REPRESENTATIVE_SELECTORS) {
const elements = document.querySelectorAll(selector);
if (!elements.length) continue;
// Sample the first matching element
const el = elements[0];
const cs = window.getComputedStyle(el);
const entry = { selector, values: {} };
for (const prop of TRACKED_PROPERTIES) {
const value = cs.getPropertyValue(prop);
if (value && value !== "none" && value !== "normal") {
entry.values[prop] = value;
}
}
results.push(entry);
}
return results;
}
One important detail: getComputedStyle always returns resolved values. Colors come
back as rgb() or rgba(), never as custom property references or named
colors. Font sizes come back in pixels. This normalization makes the next phase — deduplication
and token synthesis — much simpler because the LLM never needs to resolve aliases itself.
DOM structure and component inventory
Beyond styles, we collect structural information: heading hierarchy, nav element nesting,
common ARIA roles, repeated component patterns. This is done with
document.querySelectorAll and a walk of the DOM tree bounded by depth and node
count to avoid timing out on complex pages.
function collectDomInventory() {
return {
headingCount: {
h1: document.querySelectorAll("h1").length,
h2: document.querySelectorAll("h2").length,
h3: document.querySelectorAll("h3").length,
},
hasNav: !!document.querySelector("nav, [role='navigation']"),
hasHero: !!document.querySelector("[class*='hero'], [class*='Hero']"),
buttonVariants: collectButtonVariants(),
inputTypes: [...document.querySelectorAll("input")].map(el => el.type).filter(Boolean),
cardPatterns: detectRepeatedLayoutPatterns(),
};
}
function collectButtonVariants() {
const buttons = document.querySelectorAll("button, [role='button'], a.btn, [class*='Button']");
const variants = new Map();
for (const btn of buttons) {
const cs = window.getComputedStyle(btn);
const key = [
cs.backgroundColor,
cs.color,
cs.borderRadius,
cs.borderColor,
].join("|");
if (!variants.has(key)) {
variants.set(key, {
backgroundColor: cs.backgroundColor,
color: cs.color,
borderRadius: cs.borderRadius,
borderColor: cs.borderColor,
example: btn.textContent?.trim().slice(0, 32),
});
}
}
return [...variants.values()];
} The button variant collector is a good example of the philosophy: we're not trying to name anything yet. We're observing that there are three distinct button appearances on this page (primary filled, secondary outlined, ghost), collecting their raw computed properties, and leaving naming to the synthesis phase.
Layout geometry
We also capture getBoundingClientRect() measurements from structural elements to
infer spacing scale. If the gap between all cards is consistently 24px, and the section padding
is consistently 96px, those are likely token-aligned values. We record these measurements as
additional evidence for the LLM to correlate against the custom property map.
What the extraction phase outputs
After Phase 1, we have a structured JSON payload that looks roughly like this:
{
"customProperties": {
"--color-canvas": "#FAFAF8",
"--color-ink": "#1A1A18",
"--color-accent": "#8B5CF6",
"--space-base": "8px",
"--radius-sm": "4px",
"--radius-md": "8px",
"--font-sans": "'Inter', system-ui, sans-serif",
"--font-mono": "'JetBrains Mono', monospace"
},
"computedStyles": [
{
"selector": "body",
"values": {
"background-color": "rgb(250, 250, 248)",
"color": "rgb(26, 26, 24)",
"font-family": "Inter, system-ui, sans-serif",
"font-size": "16px",
"line-height": "1.6"
}
},
{
"selector": "h1",
"values": {
"font-size": "56px",
"font-weight": "700",
"line-height": "1.15",
"letter-spacing": "-0.03em"
}
}
],
"buttonVariants": [
{
"backgroundColor": "rgb(139, 92, 246)",
"color": "rgb(255, 255, 255)",
"borderRadius": "8px",
"example": "Get started"
},
{
"backgroundColor": "rgba(0, 0, 0, 0)",
"color": "rgb(139, 92, 246)",
"borderRadius": "8px",
"borderColor": "rgb(139, 92, 246)",
"example": "Learn more"
}
]
} No names, no interpretations, no hallucinations. Just measurements. This is the payload that gets sent to the LLM synthesis phase.
Phase 2: LLM synthesis
The extraction JSON goes to Gemini 2.5 Flash running on GCP with a structured prompt that asks it to do three things:
- Name the tokens semantically. Map raw values to names that reflect their
role, not their appearance.
rgb(250, 250, 248)becomes{colors.canvas}rather than{colors.off-white}because canvas communicates function (background surface) while off-white communicates appearance. - Detect the type scale. Cross-reference the computed font sizes against
common scale patterns (Minor Third, Major Third, Perfect Fourth, 1.25×, 1.333×) and produce
a named type scale:
{typography.display-xl},{typography.body-base}, etc. - Produce the three output files. DESIGN.md gets the full token vocabulary. STRUCTURE.md gets the component inventory. UI-KIT.md gets the usage rules and named component variants.
The key constraint in the prompt is that the LLM is not allowed to invent values. Every token it names must correspond to a value present in the extraction payload. This is enforced through prompt engineering and validated in post-processing: any token in the output whose value doesn't appear in the input is flagged and removed. The LLM does interpretation, not invention.
Why naming is an LLM job
You might reasonably ask: why not name tokens algorithmically? Given a list of colors, you could
run a clustering algorithm, sort by luminance, and name them color-1 through
color-12. The problem is that meaningless names produce meaningless prompts.
When you give an AI coding agent a token called {colors.canvas}, it
understands that this is a background surface — use it for page backgrounds and card fills.
When you give it color-1: #FAFAF8, it needs to infer that meaning from context
on every use. Semantic names encode intent; opaque names encode nothing. The extra inference
step costs tokens and introduces inconsistency.
The LLM is also good at pattern-matching to design conventions. It knows that a color named
--color-ink in a custom property is almost certainly a text color. It knows
that a font weight of 700 on an h1 but not on body suggests a
distinction between display and body weight tokens. A deterministic algorithm can't make those
connections without a rule database that would need constant maintenance.
The community cache
Running the full extraction + synthesis pipeline for a domain takes 4–8 seconds. Once a domain has been captured, the result is written to a shared cache keyed by domain. Any subsequent user who captures the same domain gets the cached result instantly — no LLM call, no wait.
The cache stores the output files (DESIGN.md, STRUCTURE.md, UI-KIT.md), not the intermediate extraction JSON. This means the cost of synthesis is paid once per domain. It also means the cache is useful even if the extraction phase changes — the stored output represents the best synthesis of that domain's design system as of when it was first captured.
Known limitations
We don't believe in hiding limitations, so here they are explicitly.
SVG fills. SVG elements use fill and stroke
attributes rather than CSS color properties. getComputedStyle does not expose
these for SVG in a way that's consistently accessible across browsers.
Icon colors are often invisible to our extraction phase. This means icon color tokens are
typically absent from the output, or inferred from the context element rather than the SVG
itself.
External @imports. We iterate document.styleSheets to collect
custom property declarations, but cross-origin sheets throw a security error when you try to
read their cssRules. We skip those sheets. If a site's custom properties are
defined exclusively in a cross-origin CSS file (e.g., a CDN-hosted design system bundle),
those property names won't appear in our enumeration — though their computed values will
still appear in the getComputedStyle output, just without the property name mapping.
Dynamic themes. Computed styles are a snapshot at capture time. If the site supports multiple themes (light/dark, brand color overrides) via JavaScript-toggled class names, we capture the current active theme only. Capturing a dark-mode variant requires setting the system preference and re-capturing.
Canvas and WebGL. Any visual content rendered on a <canvas>
element or via WebGL is invisible to our pipeline. If a site uses canvas for its hero
illustration or WebGL for a 3D background, those aren't represented in the output.
Deeply nested custom property chains. A custom property whose value references
another custom property (--button-bg: var(--color-accent)) resolves correctly
when read from getComputedStyle on an element that has both properties in scope.
But if the chain involves fallback values that differ by context, the resolved value may not
accurately represent the base token. We include the raw custom property map alongside computed
values so the LLM can attempt to trace the chain.
What this means for the output quality
The two-phase approach produces output that's unusually reliable for an AI-generated artifact. The extraction phase guarantees that every value in the output was actually present in the page. The synthesis phase guarantees that values are named according to their semantic role. Neither phase guesses.
The practical result: when you drop the DESIGN.md into a Cursor or Claude Code project and ask an agent to build a component that matches the design system, it gets the right values on the first attempt more often than when working from screenshots or verbal descriptions. The tokens encode exactly what the browser computed — nothing more, nothing less.
If you're curious about the output format itself, the post on DESIGN.md explained covers how the token vocabulary is structured and how to write effective agent prompts against it.