How to find css breakpoints of a website
Walk document.styleSheets in the console and collect the conditionText of every CSSMediaRule. That returns the widths the stylesheets declare. Then sweep the viewport a pixel at a time and compare, because container queries and clamp() change the layout at widths no media query names.
Why check this
Run this before writing the width list for How to test responsive design, and again after a design system upgrade. A width list copied from a framework's documentation tests widths the product does not use.
The defect it prevents is a breakpoint nobody tests because nobody knew it existed. On the local page below the stylesheet declares one media query, at 700 px. The layout also changes at 570 px, from a container query, and the heading size starts and stops moving at 501 px and 1001 px, from a clamp(). A list built from the declared value alone misses three of the four.
Prerequisites
- Chrome 120 or later and Node 22, with
npm i puppeteer-core. The driver ships no browser and drives the installed Chrome. - CSSMediaRule on MDN documents
conditionText, andCSSContainerRuledocumentscontainerQuery. - A page whose declared and visible breakpoints disagree on purpose. Serve these two files with the
static-server.mjsprinted in How to test responsive design, on port 8937.
<!-- bp.html -->
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pricing</title><link rel="stylesheet" href="/bp.css"></head>
<body>
<div class="wrap">
<main class="main"><h1>Pricing</h1><p>Three plans, billed monthly.</p></main>
<aside class="side"><div class="card"><p>Team plan</p><p>29 EUR per seat</p></div></aside>
</div>
</body></html>
/* bp.css */
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; }
h1 { font-size: clamp(20px, 4vw, 40px); margin: 0; }
.wrap { display: grid; grid-template-columns: 1fr; gap: 16px; padding: 16px; }
.main, .side { border: 1px solid #999; padding: 8px; }
.side { container-type: inline-size; }
.card { display: grid; grid-template-columns: 1fr; gap: 8px; }
@media (min-width: 700px) { .wrap { grid-template-columns: 2fr 1fr; } }
@container (min-width: 520px) { .card { grid-template-columns: 1fr 1fr; } }
@media print { .side { display: none; } }
- Three scripts. The first reads the CSSOM, the second reports which conditions match at a given width, the third sweeps the viewport.
// breakpoints.mjs node breakpoints.mjs <url> [url ...]
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const extract = () => {
const media = new Set(), container = new Set(), supports = new Set(), blockedFrom = [];
let sheets = 0, blocked = 0, rules = 0, fluid = 0;
const walk = (list) => {
for (const r of list) {
rules++;
const kind = r.constructor.name;
if (kind === 'CSSMediaRule') media.add(r.conditionText);
else if (kind === 'CSSContainerRule') container.add(`${r.containerName || '(unnamed)'}: ${r.containerQuery}`);
else if (kind === 'CSSSupportsRule') supports.add(r.conditionText);
if (r.style) for (const p of r.style) {
if (/\bclamp\(|\d(vw|vi|vmin)\b/.test(r.style.getPropertyValue(p))) fluid++;
}
if (r.cssRules) walk(r.cssRules);
}
};
for (const sh of document.styleSheets) {
sheets++;
try { walk(sh.cssRules); } catch { blocked++; blockedFrom.push(sh.href ? new URL(sh.href).host : '(inline)'); }
}
const root = parseFloat(getComputedStyle(document.documentElement).fontSize);
const widths = new Map();
for (const c of media) for (const m of c.matchAll(/(\d+(?:\.\d+)?)(px|r?em)\b/g)) {
const px = m[2] === 'px' ? +m[1] : +m[1] * root;
widths.set(px, m[2] === 'px' ? `${px}px` : `${px}px (${m[1]}${m[2]})`);
}
return { sheets, blocked, rules, fluid, root, blockedFrom: [...new Set(blockedFrom)],
media: [...media], container: [...container], supports: [...supports],
widths: [...widths.keys()].sort((a, b) => a - b).map((k) => widths.get(k)) };
};
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 900 });
for (const url of process.argv.slice(2)) {
await page.goto(url, { waitUntil: 'networkidle2' });
const r = await page.evaluate(extract);
console.log(url);
console.log(` ${r.sheets} stylesheets, ${r.blocked} unreadable${r.blockedFrom.length ? ' (' + r.blockedFrom.join(', ') + ')' : ''}, ${r.rules} rules, ${r.fluid} fluid declarations, root font ${r.root}px`);
for (const m of r.media) console.log(' @media ' + m);
for (const c of r.container) console.log(' @container ' + c);
for (const c of r.supports) console.log(' @supports ' + c);
console.log(' declared widths, smallest first: ' + (r.widths.join(' ') || '(none)'));
}
await browser.close();
// active-media.mjs node active-media.mjs <url> <width> [width ...]
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const [url, ...widths] = process.argv.slice(2);
const conds = () => {
const out = new Set();
const walk = (l) => { for (const r of l) { if (r.constructor.name === 'CSSMediaRule') out.add(r.conditionText); if (r.cssRules) walk(r.cssRules); } };
for (const sh of document.styleSheets) { try { walk(sh.cssRules); } catch {} }
return [...out];
};
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
const list = await page.evaluate(conds);
for (const w of widths) {
await page.setViewport({ width: Number(w), height: 900, deviceScaleFactor: 1 });
const res = await page.evaluate((l) => l.map((c) => `${matchMedia(c).matches ? 'ACTIVE ' : 'inactive'} ${c}`), list);
console.log(`viewport ${w}px`);
for (const r of res) console.log(' ' + r);
}
await browser.close();
// sweep.mjs node sweep.mjs <url> <from> <to> <grid-sel,...> <text-sel>
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const [url, from, to, grids, textSel] = process.argv.slice(2);
const gs = grids.split(',');
const read = (gs, textSel) => ({
cols: gs.map((s) => {
const el = document.querySelector(s);
return el ? `${s}=${getComputedStyle(el).gridTemplateColumns.split(' ').length}` : `${s}=absent`;
}).join(' '),
font: +parseFloat(getComputedStyle(document.querySelector(textSel)).fontSize).toFixed(2),
});
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
const rows = [];
for (let w = Number(from); w <= Number(to); w += 1) {
await page.setViewport({ width: w, height: 900, deviceScaleFactor: 1 });
rows.push({ w, ...(await page.evaluate(read, gs, textSel)) });
}
await browser.close();
console.log(`swept ${from} to ${to} px in 1 px steps`);
console.log('column count changes at:');
for (let i = 1; i < rows.length; i++) {
if (rows[i].cols !== rows[i - 1].cols) console.log(` ${rows[i].w}px ${rows[i - 1].cols} -> ${rows[i].cols}`);
}
console.log(`${textSel} font-size:`);
for (let i = 1; i < rows.length; i++) {
const moving = rows[i].font !== rows[i - 1].font;
const wasMoving = i > 1 ? rows[i - 1].font !== rows[i - 2].font : moving;
if (moving !== wasMoving) {
console.log(` ${rows[i].w}px ${rows[i - 1].font}px -> ${rows[i].font}px ${moving ? 'starts tracking the viewport' : 'stops at a clamp bound'}`);
}
}
console.log(` ${rows[0].w}px ${rows[0].font}px, ${rows[rows.length - 1].w}px ${rows[rows.length - 1].font}px`);
- Every figure below is one capture on one machine, Chrome 152.0.7977.76 on 2026-09-11.
Steps
- Step 1.
Read the conditions out of the stylesheets the page loaded. Pass the local page and a live one in the same run.
node breakpoints.mjs http://127.0.0.1:8937/bp.html https://httpbin.org/http://127.0.0.1:8937/bp.html 1 stylesheets, 0 unreadable, 12 rules, 1 fluid declarations, root font 16px @media (min-width: 700px) @media print @container (unnamed): (min-width: 520px) declared widths, smallest first: 700px https://httpbin.org/ 3 stylesheets, 1 unreadable (fonts.googleapis.com), 2504 rules, 0 fluid declarations, root font 16px @media screen and (min-width: 30em) @media screen and (min-width: 30em) and (max-width: 60em) @media screen and (min-width: 60em) @media (max-width: 768px) declared widths, smallest first: 480px (30em) 768px 960px (60em)1 unreadableis the line to read first. A stylesheet from another origin withoutAccess-Control-Allow-Originthrows oncssRules, and the loop swallows it, so any breakpoint in that file is missing from the list. Thefluidcount warns that one declaration changes with viewport width and belongs to no query. - Step 2.
Ask the browser which conditions match, at a width on each side of the declared one.
node active-media.mjs http://127.0.0.1:8937/bp.html 390 800viewport 390px inactive (min-width: 700px) inactive print viewport 800px ACTIVE (min-width: 700px) inactive printmatchMediatakes the same string the CSSOM returned, so the conditions never have to be retyped. It has no equivalent for container queries, which is why the 520 px rule from step 1 appears nowhere here. - Step 3.
Sweep the viewport one pixel at a time and record where the layout actually moves.
node sweep.mjs http://127.0.0.1:8937/bp.html 320 1100 ".wrap,.card" "h1"swept 320 to 1100 px in 1 px steps column count changes at: 570px .wrap=1 .card=1 -> .wrap=1 .card=2 700px .wrap=1 .card=2 -> .wrap=2 .card=1 h1 font-size: 501px 20px -> 20.04px starts tracking the viewport 1001px 40px -> 40px stops at a clamp bound 320px 20px, 1100px 40pxFour widths matter and one was declared. 570 px is the container query: the sidebar's content box reaches 520 px there, once the wrapper's padding and the card's border and padding are taken off. At 700 px the media query narrows that sidebar below 520 px again, so the card drops back to one column at the width the page gets wider. The
clamp(20px, 4vw, 40px)heading is fixed until 501 px, tracks the viewport to 1001 px, then is fixed again. - Step 4.
Run the extraction against a site whose stylesheets you did not write.
node breakpoints.mjs https://developer.mozilla.org/en-US/docs/Web/HTTPhttps://developer.mozilla.org/en-US/docs/Web/HTTP 17 stylesheets, 0 unreadable, 507 rules, 3 fluid declarations, root font 16px @media (prefers-color-scheme: dark) @media print @media (width >= calc(67rem)) @media (width < calc(50rem)) @media (width <= 426px) @media (width <= 992px) @media not (width <= 769px) @media (width <= 769px) or (width < calc(50rem)) @media (width <= 1044px) @media screen and (width <= 480px) @media (width > 640px) … declared widths, smallest first: 426px 480px 640px 769px 800px (50rem) 840px 992px 1000px 1044px 1072px (67rem) 1104pxEleven widths, none written as
min-widthormax-width. This stylesheet uses range syntax throughout, so a text search formin-widthacross the CSS files returns nothing and the page looks like it has no breakpoints. Two values are inrem, resolved here against the 16 px root font, so they move if the root size changes.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 0 unreadable | Every stylesheet was same-origin or CORS-enabled | The condition list is complete for media and container rules. |
| 1 unreadable (host) | A cross-origin stylesheet blocked cssRules | Fetch that file directly and read its conditions, or the list is short by an unknown amount. |
| A @container line | A rule keyed to an element width, not the viewport | Sweep. The viewport width where it fires depends on the layout above it. |
| fluid declarations above zero | Something scales with viewport width continuously | The sweep reports the two widths where the value stops changing. |
| A sweep change at a width that is not declared | A container query, a clamp() bound, or a wrapping flex row | Locate the source before adding the width to a test list. |
| A declared width with no sweep change | A dead rule, or a rule affecting something the sweep does not read | Add the selector it targets to the sweep before deleting the rule. |
Common mistakes
What to check next
- How to test responsive design: the width sweep this list feeds, with pass and fail per width.
- How to check viewport meta tag: without it, no media query below 980 px fires on a phone.
- How to find what causes horizontal scroll on mobile: the failure that shows up between two breakpoints.
- How to test print styles of a page: the
@media printblock that appears in every extraction above and is rarely opened. - How to check render blocking resources: the same stylesheet list, costed by load time.
FAQ
What are breakpoints in responsive design?
Widths at which a stylesheet changes the layout. Classically they are media query conditions such as (min-width: 700px). Container queries and clamp() add widths that behave the same way for a user and appear in no media query, which is why the sweep in step 3 exists.
How do I check which media query is active?
Run step 2, or paste the condition into matchMedia('(min-width: 700px)').matches in the console. DevTools also shows it: in the Elements panel, a rule inside an inactive media query is listed with its condition above it and is not applied to the computed styles.
Why does DevTools show breakpoints I cannot find in the CSS?
The Styles pane resolves @import, @layer and @supports nesting, and it reads sheets injected by script. The walk in step 1 recurses through the same nesting. A rule visible in DevTools and absent from the extraction usually came from a cross-origin sheet counted as unreadable.
Do container queries need a different check?
Yes. matchMedia cannot evaluate them and their trigger width depends on the layout above the container. Extract them from the CSSOM to know they exist, then sweep to learn which viewport widths make them fire.
Verified
Verified by Maks VernyChrome 152.0.7977.76Node 22.23.2puppeteer-core 25.10.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny