How to test responsive design
Load the page once, set the viewport to each width on an agreed list, and assert three things at every width: the document does not scroll sideways, no text falls below the minimum font size, and the layout changes where a breakpoint is declared. Read the verdict column of the table the script prints.
Why check this
Run the sweep on every template before staging sign-off, and on templates a release touched during regression. Dragging the window corner produces no record and covers different widths each time.
The defect it catches is a fixed-width element that survives into a phone layout. Below, a navigation bar with min-width: 420px pushes the document 122 pixels wider than a 360 pixel viewport, so every page using that header scrolls sideways on a phone.
The three assertions fail independently. A page can pass overflow and fail font size, and it can pass both while still showing the desktop layout on a phone, which step 4 demonstrates.
Prerequisites
- Chrome 120 or later and Node 22, with
npm i puppeteer-core. The driver ships no browser and drives the installed Chrome. - A width list: 360, 390, 414, 768, 1024, 1280, 1440 below. Take yours from product analytics, and add one width on each side of every declared breakpoint.
- A minimum font size, agreed once and asserted. The example uses 12 px.
- A container selector, the element whose children rearrange. Here it is
.grid. - The page under test, with both defects on purpose. Serve it with
node static-server.mjs 8937and stop the server by PID afterwards.
<!-- app.html -->
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fleet dashboard</title><link rel="stylesheet" href="/app.css"></head>
<body>
<header class="bar"><span class="brand">Fleet</span>
<nav class="toolbar"><a href="#">Vehicles</a><a href="#">Drivers</a><a href="#">Routes</a><a href="#">Invoices</a><a href="#">Settings</a></nav>
</header>
<main class="grid">
<section class="panel"><h1>Active routes</h1><p>14 vehicles on the road.</p>
<table class="report"><tr><th>Route</th><th>Driver</th><th>ETA</th><th>Load</th></tr>
<tr><td>A-114 north</td><td>K. Osei</td><td>14:20</td><td>820 kg</td></tr></table></section>
<aside class="side"><h2>Alerts</h2><p>2 vehicles need service.</p>
<p class="legal">Times are local to the depot and refresh every 60 seconds.</p></aside>
</main>
</body></html>
/* app.css */
:root { --gap: 16px; }
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; }
.bar { display: flex; gap: var(--gap); align-items: center; padding: 12px; border-bottom: 1px solid #999; }
.toolbar { display: flex; gap: var(--gap); min-width: 420px; }
.grid { display: grid; grid-template-columns: 1fr; gap: var(--gap); padding: var(--gap); }
.panel, .side { border: 1px solid #999; padding: 12px; }
.report { border-collapse: collapse; width: 100%; }
.report th, .report td { border: 1px solid #ccc; padding: 4px 8px; white-space: nowrap; }
.legal { font-size: 11px; color: #555; }
@media (min-width: 768px) { .grid { grid-template-columns: 2fr 1fr; } }
@media (min-width: 1200px) { .grid { grid-template-columns: 3fr 1fr; } .legal { font-size: 13px; } }
/* app-fixed.css is app.css with these two lines replaced, and app-fixed.html
points at it. app-fixed-notag.html is app-fixed.html with the viewport
meta line deleted.
.toolbar { display: flex; gap: var(--gap); flex-wrap: wrap; }
.legal { font-size: 12px; color: #555; }
*/
// static-server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const port = Number(process.argv[2] || 8937);
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css' };
createServer(async (req, res) => {
const path = req.url.split('?')[0];
try {
const body = await readFile(new URL('.' + path, import.meta.url));
res.writeHead(200, { 'content-type': types[path.slice(path.lastIndexOf('.'))] ?? 'text/plain' });
res.end(body);
} catch {
res.writeHead(404, { 'content-type': 'text/plain' });
res.end('not found');
}
}).listen(port, () => console.log('serving on http://127.0.0.1:' + port + '/'));
- The sweep. It loads the page once and resizes, so a live site takes one request, not one per width.
// responsive.mjs: node responsive.mjs <url> <container-selector> <min-font-px>
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const [url, sel, minFont] = process.argv.slice(2);
const WIDTHS = [360, 390, 414, 768, 1024, 1280, 1440];
const probe = (sel, min) => {
const vw = document.documentElement.clientWidth;
const name = (el) => el.tagName.toLowerCase() +
(typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/)[0] : '');
const past = [...document.querySelectorAll('body *')]
.map((el) => ({ el, r: el.getBoundingClientRect() }))
.filter(({ r }) => r.width > 0 && r.right > vw + 1);
const small = new Set();
for (const el of document.querySelectorAll('body *')) {
if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())) continue;
const fs = parseFloat(getComputedStyle(el).fontSize);
if (fs < min) small.add(`${name(el)}=${fs}px`);
}
const c = document.querySelector(sel);
const cw = c.getBoundingClientRect().width || 1;
const kids = [...c.children].filter((k) => k.getBoundingClientRect().width > 0);
const rows = new Set(kids.map((k) => Math.round(k.getBoundingClientRect().top))).size;
const share = kids.map((k) => Math.round((k.getBoundingClientRect().width / cw) * 10) * 10).join('/');
return {
scroll: document.documentElement.scrollWidth - vw,
past: past.length,
culprit: past.length ? `${name(past[0].el)}@${Math.round(past[0].r.right)}` : '-',
small: [...small][0] ?? '',
sig: `rows=${rows} share=${share}`,
};
};
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
let prev = null;
console.log('width scroll past first element past the edge font layout verdict');
for (const w of WIDTHS) {
await page.setViewport({ width: w, height: 844, deviceScaleFactor: 2, isMobile: w <= 480, hasTouch: w <= 480 });
await new Promise((r) => setTimeout(r, 250));
const p = await page.evaluate(probe, sel, Number(minFont));
const fail = [];
if (p.scroll > 1) fail.push('overflow');
if (p.small) fail.push('font');
console.log(
String(w).padEnd(6),
(p.scroll > 1 ? `+${p.scroll}` : 'none').padEnd(7),
String(p.past).padEnd(5),
p.culprit.padEnd(28),
(p.small || 'ok').padEnd(11),
p.sig.padEnd(18),
(fail.length ? 'FAIL ' + fail.join('+') : 'PASS') + (prev === null ? '' : p.sig === prev ? '' : ' CHANGED')
);
prev = p.sig;
}
await browser.close();
- Every figure below is one capture on one machine, Chrome 152.0.7977.76 on 2026-09-11.
Steps
- Step 1.
Sweep the page under test. The third argument is the minimum font size.
node responsive.mjs http://127.0.0.1:8937/app.html ".grid" 12width scroll past first element past the edge font layout verdict 360 +122 2 nav.toolbar@482 p.legal=11px rows=2 share=90/90 FAIL overflow+font 390 +92 2 nav.toolbar@482 p.legal=11px rows=2 share=90/90 FAIL overflow+font 414 +68 1 nav.toolbar@482 p.legal=11px rows=2 share=90/90 FAIL overflow+font 768 none 0 - p.legal=11px rows=1 share=60/30 FAIL font CHANGED 1024 none 0 - p.legal=11px rows=1 share=60/30 FAIL font 1280 none 0 - ok rows=1 share=70/20 PASS CHANGED 1440 none 0 - ok rows=1 share=70/20 PASSFive of seven widths fail.
scrollis the document overflow in pixels and it alone decides the overflow verdict.nav.toolbar@482says the toolbar's right edge sits at 482 while the viewport ends at 360, naming the culprit without the inspector.CHANGEDmarks the two widths where the layout moved, 768 and 1280, matching the two declared media queries. - Step 2.
Fix both defects and sweep again:
min-width: 420pxbecomesflex-wrap: wrap, and.legalgoes to 12 px.node responsive.mjs http://127.0.0.1:8937/app-fixed.html ".grid" 12width scroll past first element past the edge font layout verdict 360 none 0 - ok rows=2 share=90/90 PASS 390 none 0 - ok rows=2 share=90/90 PASS 414 none 0 - ok rows=2 share=90/90 PASS 768 none 0 - ok rows=1 share=60/30 PASS CHANGED 1024 none 0 - ok rows=1 share=60/30 PASS 1280 none 0 - ok rows=1 share=70/20 PASS CHANGED 1440 none 0 - ok rows=1 share=70/20 PASSSeven passes, and
rows=2at the phone widths says the two panels are stacked there. Attach this table to the ticket: it records the widths tested and the container measured. - Step 3.
Sweep a live site, one nobody wrote for this exercise.
node responsive.mjs https://www.cloudflare.com/ "main" 12width scroll past first element past the edge font layout verdict 360 none 507 div.pointer-events-none@444 ok rows=1 share=100 PASS 390 none 507 div.pointer-events-none@482 ok rows=1 share=100 PASS 414 none 508 div.pointer-events-none@512 ok rows=1 share=100 PASS 768 none 527 div.bg-background-100@984 ok rows=1 share=100 PASS 1024 none 505 div.bg-background-100@1112 ok rows=1 share=100 PASS 1280 none 385 div.absolute@1380 ok rows=1 share=100 PASS 1440 none 301 div.absolute@1460 ok rows=1 share=100 PASS507 elements reach past the right edge at 360 px and the document scrolls sideways at no width. Decorative layers, clipped panels and off-canvas menus sit past the edge by design, absorbed by an ancestor with
overflowhidden. A check that fails on "an element sticks out" reports 507 defects here.share=100showsmainholds one child, so the layout column needs a container chosen by hand. - Step 4.
Sweep the fixed page again with the viewport meta tag deleted.
node responsive.mjs http://127.0.0.1:8937/app-fixed-notag.html ".grid" 12width scroll past first element past the edge font layout verdict 360 none 0 - ok rows=1 share=60/30 PASS 390 none 0 - ok rows=1 share=60/30 PASS 414 none 0 - ok rows=1 share=60/30 PASS 768 none 0 - ok rows=1 share=60/30 PASS 1024 none 0 - ok rows=1 share=60/30 PASS 1280 none 0 - ok rows=1 share=70/20 PASS CHANGED 1440 none 0 - ok rows=1 share=70/20 PASSSeven passes on a page that is broken on every phone. The layout viewport was 980 px at all three phone widths, so the desktop two-column layout ran there and nothing overflowed it. The tell is the layout column:
rows=1 share=60/30at 360 px, identical to 1024 px, and oneCHANGEDmarker where there should be two.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| scroll +122 | The document is 122 px wider than the viewport at that width | Read the culprit column, then follow the containing chain outward from that element. |
| past above zero with scroll none | Elements sit past the edge and an ancestor clips them | Not a defect on its own. Check it only if content the reader needs is among them. |
| A font value in the font column | Text is below the agreed minimum at that width | Fix the rule, or change the agreed minimum. Do not silence the column. |
| The same layout signature at a phone and a desktop width | The breakpoint did not fire | Check the viewport meta tag first, then the media query condition. |
| CHANGED at a width you did not declare | A container query, a clamp() or a wrapping flex row moved the layout | Find the source before shipping. The width sweep says where, not why. |
| Fewer CHANGED markers than declared breakpoints | A media query is dead, or the container selector is wrong | Confirm the selector holds the children that rearrange. |
Common mistakes
What to check next
- How to check viewport meta tag: the precondition. Step 4 shows the sweep passing wrongly without it.
- How to find css breakpoints of a website: where the width list comes from, read out of the stylesheets.
- How to find what causes horizontal scroll on mobile: what to do with the culprit column.
- How to check which srcset image the browser loaded: the other thing width changes, invisible to the layout columns.
- How to check tap target size: the assertion to add at phone widths once the layout is correct.
FAQ
How do I test responsive design in Chrome?
Open DevTools, click the device toolbar icon, and set a width there rather than resizing the window. That turns on the mobile viewport pipeline, so the viewport meta tag applies and device pixel ratio is emulated. The script does the same through setViewport with isMobile true.
How do I check if a website is responsive?
Run the sweep and read the layout column. A responsive page changes its layout at least once between a phone width and a desktop width, and scrolls sideways at none of them. A page whose signature is identical at 360 px and 1440 px is not responsive, whatever the stylesheet claims.
How do I test mobile view in Chrome without a phone?
Device emulation covers layout, the viewport meta tag, device pixel ratio and touch events. It does not cover the iOS browser engine, system font scaling, or a notch. Use it for these assertions and keep one real device for sign-off.
How many screen sizes should I test?
One on each side of every declared breakpoint, plus the narrowest width the product supports. Widths taken from a device catalogue age badly. The seven here cover small phones, large phones, tablets and two desktop sizes.
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
intermediate10 minpublished updated Maks Verny