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

<!-- 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 + '/'));
// 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();

Steps

  1. 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" 12
    
    width  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 PASS

    Five of seven widths fail. scroll is the document overflow in pixels and it alone decides the overflow verdict. nav.toolbar@482 says the toolbar's right edge sits at 482 while the viewport ends at 360, naming the culprit without the inspector. CHANGED marks the two widths where the layout moved, 768 and 1280, matching the two declared media queries.

  2. Step 2.

    Fix both defects and sweep again: min-width: 420px becomes flex-wrap: wrap, and .legal goes to 12 px.

    node responsive.mjs http://127.0.0.1:8937/app-fixed.html ".grid" 12
    
    width  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 PASS

    Seven passes, and rows=2 at 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.

  3. Step 3.

    Sweep a live site, one nobody wrote for this exercise.

    node responsive.mjs https://www.cloudflare.com/ "main" 12
    
    width  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   PASS

    507 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 overflow hidden. A check that fails on "an element sticks out" reports 507 defects here. share=100 shows main holds one child, so the layout column needs a container chosen by hand.

  4. 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" 12
    
    width  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 PASS

    Seven 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/30 at 360 px, identical to 1024 px, and one CHANGED marker 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

Sign: Any element whose right edge passes the viewport is reported as an overflow defect.Cause: On cloudflare.com at 360 px, 507 elements sit past the right edge and the document does not scroll at all. Decorative layers and off-canvas panels are meant to be there and an ancestor clips them. The verdict belongs to documentElement.scrollWidth. Elements past the edge are where to start looking once scrollWidth already says there is a problem.
Sign: The sweep passes at every width, and the page is still wrong on a phone.Cause: Step 4 is that result. Without a viewport meta tag the layout viewport is 980 px whatever width you set, so the desktop layout runs at 360 px and fits. Two signals separate it from a real pass: the layout signature at a phone width equals the one at 1024 px, and a declared breakpoint produces no CHANGED marker.
Sign: The phone widths are tested in a desktop browser window narrowed with the mouse.Cause: A desktop window ignores the viewport meta tag, so the missing-tag case cannot appear and device pixel ratio is wrong for any image check. The script sets isMobile true at 480 px and below for that reason. DevTools device toolbar does the same. A narrow window does not.
Sign: The layout signature is read as an exact percentage and reported as a change on every pixel.Cause: Column shares drift by one percent as fixed padding meets a changing viewport. The share in this script is rounded to the nearest ten before comparison, which is why 360 px and 390 px both read share=90/90 instead of 91/91 and 92/92. Without that rounding every row is marked CHANGED and the column says nothing.

What to check next

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.

intermediate10 minpublished updated Maks Verny