Extract a brand kit from any website
Last updated Oct 12, 2026 A brand kit is the set of visual design decisions that make a website recognizable: its colors, typography, logo, and component styles. Extracting one automatically is useful for building design-system tooling, generating themed previews, seeding a coding agent with a site's look and feel, or auditing brand consistency across pages.
The reliable way to read these values is from a real browser. A site's exact hex colors, font-family names, border radii, and logo live in CSS and computed styles, not in the page's readable text. A large language model that only sees text will get the name and description right, but it will hallucinate the colors and fonts. With Cloudflare Browser Run and Cloudflare's fork of Puppeteer, you can load a page in managed headless Chrome and read the real computed styles, meta tags, and logo straight from the DOM.
Try it live
You can see the finished extractor in action at the Browser Run demo ↗︎. Enter any URL to view the brand kit it pulls from the live page.
In this tutorial, you will:
- Add a Browser Run binding and Puppeteer to a Worker
- Run an in-page script that reads computed styles, fonts, and the logo
- Score and dedupe colors to find the brand's palette
- Return the brand kit as JSON
- Cache results in Workers KV so repeat lookups are instant
Prerequisites
To follow this tutorial, you need:
- A Cloudflare account with Browser Run enabled
- Node.js and npm installed locally
- Basic familiarity with TypeScript and Cloudflare Workers
1. Create a Worker project
Create a new Worker project named brand-kit:
npmyarnpnpm
npm create cloudflare@latest -- brand-kit
yarn create cloudflare brand-kit
pnpm create cloudflare@latest brand-kit
When prompted, choose a Hello World Worker using TypeScript.
2. Install Puppeteer
In your brand-kit directory, install Cloudflare's fork of Puppeteer:
npmyarnpnpmbun
npm i -D @cloudflare/puppeteer
yarn add -D @cloudflare/puppeteer
pnpm add -D @cloudflare/puppeteer
bun add -d @cloudflare/puppeteer
3. Create a KV namespace
Extracting a brand kit drives a full browser session, so you want to cache the result and avoid re-running the browser for repeat lookups of the same URL. Create a KV namespace for the cache:
npmyarnpnpm
npx wrangler kv namespace create CACHE
yarn wrangler kv namespace create CACHE
pnpm wrangler kv namespace create CACHE
Copy the namespace id from the command output. You will add it to your Wrangler configuration in the next step.
4. Configure Browser Run
Add a Browser Run binding, the KV namespace, and the Node.js compatibility flag (required by Puppeteer) to your Wrangler configuration:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "brand-kit",
"main": "src/index.ts",
"upload_source_maps": true,
// Set this to today's date
"compatibility_date": "2026-10-27",
"compatibility_flags": [
"nodejs_compat"
],
"browser": {
"binding": "BROWSER"
},
"kv_namespaces": [
{
"binding": "CACHE",
"id": "<YOUR_KV_NAMESPACE_ID>"
}
]
}
name = "brand-kit"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
compatibility_flags = ["nodejs_compat"]
# Browser Run binding — driven by Puppeteer.
[browser]
binding = "BROWSER"
# KV cache for extracted brand kits.
[[kv_namespaces]]
binding = "CACHE"
id = "<YOUR_KV_NAMESPACE_ID>"
Note
Puppeteer requires the nodejs_compat compatibility flag and a compatibility_date of 2025-09-15 or later.
5. Write the in-page extraction script
The core of the extractor is a script that runs inside the page through page.evaluate(). It reads computed styles, meta tags, and the logo, then returns the brand kit as a JSON string. Returning a primitive string (rather than a live object) makes serialization across the browser boundary reliable.
This script must be self-contained: it cannot reference anything from the Worker's scope. It does three things:
- Reads
<meta>tags for the name and description. - Walks the DOM once, sampling
background-color,color, andborder-colorfrom every element. It skips greys and near-white/near-black colors (low saturation), weighs backgrounds and brand or call-to-action elements more heavily, then dedupes the top colors by color distance to produce a palette. - Reads the
font-familyfrom a heading, the body, and a code block, and picks a logo (inline SVG,<img>, declared icon, or Open Graph image, in that order).
Create src/extract.ts:
// This runs in the page (headless Chrome). It must be self-contained and
// return a JSON string, which page.evaluate() serializes reliably.
export const BRAND_SCRIPT = `
(() => {
var origin = location.href;
var abs = function (u) { try { return new URL(u, origin).href; } catch (e) { return u; } };
var getMetaContent = function (sel) { var m = document.querySelector(sel); return m && m.content ? m.content.trim() : null; };
function parseColor(str) {
if (!str) return null;
var m = str.match(/rgba?\\(([^)]+)\\)/);
if (!m) return null;
var p = m[1].split(',').map(function (x) { return parseFloat(x.trim()); });
var a = p.length > 3 ? p[3] : 1;
if (a === 0) return null;
return { r: p[0], g: p[1], b: p[2] };
}
function toHex(c) {
var h = function (n) { var s = Math.max(0, Math.min(255, Math.round(n))).toString(16); return s.length < 2 ? '0' + s : s; };
return ('#' + h(c.r) + h(c.g) + h(c.b)).toUpperCase();
}
function saturation(c) {
var r = c.r / 255, g = c.g / 255, b = c.b / 255;
var max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
if (d === 0) return 0;
var l = (max + min) / 2;
return l > 0.5 ? d / (2 - max - min) : d / (max + min);
}
function lightness(c) {
var max = Math.max(c.r, c.g, c.b) / 255, min = Math.min(c.r, c.g, c.b) / 255;
return (max + min) / 2;
}
function dist(a, b) {
return Math.sqrt(Math.pow(a.r - b.r, 2) + Math.pow(a.g - b.g, 2) + Math.pow(a.b - b.b, 2));
}
function cleanFont(ff) {
if (!ff) return null;
var generic = ['sans-serif', 'serif', 'monospace', 'system-ui', '-apple-system', 'ui-sans-serif', 'ui-monospace', 'blinkmacsystemfont', 'segoe ui', 'roboto', 'helvetica neue', 'arial'];
var parts = ff.split(',').map(function (x) { return x.trim().replace(/^["']|["']$/g, ''); });
for (var k = 0; k < parts.length; k++) { if (parts[k] && generic.indexOf(parts[k].toLowerCase()) < 0) return parts[k]; }
return parts[0] || null;
}
// ---- colors: a single pass over the DOM, scoring brand colors ----
var scored = {};
function add(c, weight) {
var hex = toHex(c);
if (!scored[hex]) scored[hex] = { score: 0, c: c };
scored[hex].score += weight;
}
// theme-color is an explicit brand signal — weigh it heavily.
var themeColor = getMetaContent('meta[name="theme-color"]');
if (themeColor) { var d = document.createElement('div'); d.style.color = themeColor; document.body.appendChild(d); var tc = parseColor(getComputedStyle(d).color); d.remove(); if (tc) add(tc, 1000); }
var all = document.querySelectorAll('body *');
var limit = Math.min(all.length, 4000);
for (var i = 0; i < limit; i++) {
var el = all[i];
var cs = getComputedStyle(el);
var tag = el.tagName.toLowerCase();
var cls = (el.className && el.className.toString ? el.className.toString() : '').toLowerCase();
// Buttons, links, and brand/CTA/nav elements are stronger brand signals.
var emphasis = (tag === 'button' || tag === 'a' || /btn|cta|button|brand|logo|nav|header|primary|hero/.test(cls)) ? 3 : 1;
var props = [cs.backgroundColor, cs.color, cs.borderColor];
for (var j = 0; j < props.length; j++) {
var col = parseColor(props[j]);
if (!col) continue;
if (saturation(col) < 0.2) continue; // skip greys / near-white / near-black
var lt = lightness(col);
if (lt < 0.07 || lt > 0.95) continue;
add(col, (j === 0 ? 2 : 1) * emphasis); // backgrounds weigh more than text
}
}
var entries = Object.keys(scored).map(function (h) { return { hex: h, score: scored[h].score, c: scored[h].c }; });
entries.sort(function (a, b) { return b.score - a.score; });
var picked = [];
for (var e = 0; e < entries.length && picked.length < 5; e++) {
var ok = true;
for (var p = 0; p < picked.length; p++) { if (dist(entries[e].c, picked[p].c) < 40) { ok = false; break; } }
if (ok) picked.push(entries[e]);
}
var roles = ['primary', 'secondary', 'accent', 'color-4', 'color-5'];
var colors = picked.map(function (c, idx) { return { name: roles[idx], hex: c.hex }; });
// ---- color scheme ----
var bgEl = parseColor(getComputedStyle(document.body).backgroundColor);
var colorScheme = (bgEl && lightness(bgEl) < 0.45) ? 'dark' : 'light';
// ---- fonts: headings, body, mono ----
var headingEl = document.querySelector('h1, h2, h3');
var headingFont = headingEl ? cleanFont(getComputedStyle(headingEl).fontFamily) : null;
var bodyFont = cleanFont(getComputedStyle(document.body).fontFamily);
var monoEl = document.querySelector('code, pre, kbd');
var monoFont = monoEl ? cleanFont(getComputedStyle(monoEl).fontFamily) : null;
var fonts = [];
if (headingFont) fonts.push({ name: headingFont, usage: 'headings' });
if (bodyFont && bodyFont.toLowerCase() !== (headingFont || '').toLowerCase()) fonts.push({ name: bodyFont, usage: 'body' });
if (monoFont && monoFont.toLowerCase() !== (bodyFont || '').toLowerCase()) fonts.push({ name: monoFont, usage: 'mono' });
// ---- logo: inline SVG (as a data URI) → <img> logo → declared icon → Open Graph image ----
function serializeSvg(svg) {
try {
var clone = svg.cloneNode(true);
if (!clone.getAttribute('xmlns')) clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
var str = new XMLSerializer().serializeToString(clone);
if (str.length > 30000) return null;
return 'data:image/svg+xml,' + encodeURIComponent(str);
} catch (e) { return null; }
}
function pickLogo() {
var svgSels = ['a[href="/"] svg', '[class*="logo" i] svg', 'header svg', 'nav svg'];
for (var s = 0; s < svgSels.length; s++) {
var svg = document.querySelector(svgSels[s]);
if (svg) { var rc = svg.getBoundingClientRect(); if (rc.width >= 16 && rc.width <= 640 && rc.height >= 8) { var data = serializeSvg(svg); if (data) return data; } }
}
var imgSels = ['[class*="logo" i] img', 'img[alt*="logo" i]', 'header a[href="/"] img', 'header img'];
for (var i2 = 0; i2 < imgSels.length; i2++) {
var img = document.querySelector(imgSels[i2]);
// Guard against a full-width banner image matching a "logo" selector by
// rejecting anything outside a plausible logo size, the same way the
// SVG branch above does with getBoundingClientRect().
if (img && img.naturalWidth >= 16 && img.naturalWidth <= 640 && img.naturalHeight >= 8) {
var src = img.currentSrc || img.getAttribute('src');
if (src) return abs(src);
}
}
var icon = document.querySelector('link[rel="apple-touch-icon"], link[rel~="icon"]');
if (icon && icon.href) return abs(icon.href);
var ogImage = getMetaContent('meta[property="og:image"]');
if (ogImage) return abs(ogImage);
return '';
}
var name = getMetaContent('meta[property="og:site_name"]') ||
(document.title ? document.title.split(/[-|–—·•:]/)[0].trim() : '') ||
location.hostname.replace(/^www\\./, '');
var description = getMetaContent('meta[name="description"]') || getMetaContent('meta[property="og:description"]') || '';
return JSON.stringify({
name: name,
description: description,
logoUrl: pickLogo(),
colorScheme: colorScheme,
colors: colors,
fonts: fonts
});
})();
`;
```ts export type BrandColor = { name: string; hex: string }; export type BrandFont = { name: string; usage: string };
export type BrandKit = { name: string; description: string; logoUrl: string; colorScheme: "light" | "dark"; colors: BrandColor[]; fonts: BrandFont[]; };
// This runs in the page (headless Chrome). It must be self-contained and // return a JSON string, which page.evaluate() serializes reliably. export const BRAND_SCRIPT = ` (() => { var origin = location.href; var abs = function (u) { try { return new URL(u, origin).href; } catch (e) { return u; } }; var getMetaContent = function (sel) { var m = document.querySelector(sel); return m && m.content ? m.content.trim() : null; };
function parseColor(str) { if (!str) return null; var m = str.match(/rgba?\(([^)]+)\)/); if (!m) return null; var p = m[1].split(',').map(function (x) { return parseFloat(x.trim()); }); var a = p.length > 3 ? p[3] : 1; if (a === 0) return null; return { r: p[0], g: p[1], b: p[2] }; } function toHex(c) { var h = function (n) { var s = Math.max(0, Math.min(255, Math.round(n))).toString(16); return s.length < 2 ? '0' + s : s; }; return ('#' + h(c.r) + h(c.g) + h(c.b)).toUpperCase(); } function saturation(c) { var r = c.r / 255, g = c.g / 255, b = c.b / 255; var max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min; if (d === 0) return 0; var l = (max + min) / 2; return l > 0.5 ? d / (2 - max - min) : d / (max + min); } function lightness(c) { var max = Math.max(c.r, c.g, c.b) / 255, min = Math.min(c.r, c.g, c.b) / 255; return (max + min) / 2; } function dist(a, b) { return Math.sqrt(Math.pow(a.r - b.r, 2) + Math.pow(a.g - b.g, 2) + Math.pow(a.b - b.b, 2)); } function cleanFont(ff) { if (!ff) return null; var generic = ['sans-serif', 'serif', 'monospace', 'system-ui', '-apple-system', 'ui-sans-serif', 'ui-monospace', 'blinkmacsystemfont', 'segoe ui', 'roboto', 'helvetica neue', 'arial']; var parts = ff.split(',').map(function (x) { return x.trim().replace(/^["']|["']$/g, ''); }); for (var k = 0; k < parts.length; k++) { if (parts[k] && generic.indexOf(parts[k].toLowerCase()) < 0) return parts[k]; } return parts[0] || null; }
// ---- colors: a single pass over the DOM, scoring brand colors ---- var scored = {}; function add(c, weight) { var hex = toHex(c); if (!scored[hex]) scored[hex] = { score: 0, c: c }; scored[hex].score += weight; }
// theme-color is an explicit brand signal — weigh it heavily. var themeColor = getMetaContent('meta[name="theme-color"]'); if (themeColor) { var d = document.createElement('div'); d.style.color = themeColor; document.body.appendChild(d); var tc = parseColor(getComputedStyle(d).color); d.remove(); if (tc) add(tc, 1000); }
var all = document.querySelectorAll('body *'); var limit = Math.min(all.length, 4000); for (var i = 0; i < limit; i++) { var el = all[i]; var cs = getComputedStyle(el); var tag = el.tagName.toLowerCase(); var cls = (el.className && el.className.toString ? el.className.toString() : '').toLowerCase(); // Buttons, links, and brand/CTA/nav elements are stronger brand signals. var emphasis = (tag === 'button' || tag === 'a' || /btn|cta|button|brand|logo|nav|header|primary|hero/.test(cls)) ? 3 : 1; var props = [cs.backgroundColor, cs.color, cs.borderColor]; for (var j = 0; j < props.length; j++) { var col = parseColor(props[j]); if (!col) continue; if (saturation(col) < 0.2) continue; // skip greys / near-white / near-black var lt = lightness(col); if (lt < 0.07 || lt > 0.95) continue; add(col, (j === 0 ? 2 : 1) * emphasis); // backgrounds weigh more than text } }
