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

<!-- 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; } }
// 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`);

Steps

  1. 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 unreadable is the line to read first. A stylesheet from another origin without Access-Control-Allow-Origin throws on cssRules, and the loop swallows it, so any breakpoint in that file is missing from the list. The fluid count warns that one declaration changes with viewport width and belongs to no query.

  2. 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 800
    
    viewport 390px
    inactive  (min-width: 700px)
    inactive  print
    viewport 800px
    ACTIVE    (min-width: 700px)
    inactive  print

    matchMedia takes 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.

  3. 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 40px

    Four 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.

  4. 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/HTTP
    
    https://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)  1104px

    Eleven widths, none written as min-width or max-width. This stylesheet uses range syntax throughout, so a text search for min-width across the CSS files returns nothing and the page looks like it has no breakpoints. Two values are in rem, 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

Sign: A text search for min-width or max-width across the stylesheets returns nothing, so the site is recorded as having no breakpoints.Cause: MDN's stylesheets declare eleven widths and use range syntax for all of them: (width <= 769px), (width < calc(50rem)), (width <= 1044px). The CSSOM reports these as conditionText and a text search does not. Read the parsed rules, not the source text.
Sign: The extracted list is treated as complete.Cause: document.styleSheets includes cross-origin sheets, and reading cssRules on one throws a SecurityError that a try block hides. On httpbin.org one of three sheets is blocked that way. The count of unreadable sheets belongs in the output, otherwise a short list looks like a clean one.
Sign: Breakpoints are found by dragging the window and watching for a jump.Cause: Dragging finds changes big enough to see, at whatever widths the mouse passed through. On the page above it would find 700 px and probably 570 px, and never the clamp bounds at 501 px and 1001 px, where the heading changes from fixed to fluid without moving anything.
Sign: A container query is added to the width list as if it were a media query.Cause: A container query fires on the container's own width. Here the 520 px rule turns on at a 570 px viewport and off again at 700 px, when the media query shrinks the sidebar. The same rule produces two visible changes, in opposite directions, at two viewport widths that are nowhere in the CSS.

What to check next

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.

intermediate8 minpublished updated Maks Verny