How to test a page at 200 percent zoom
Set the CSS viewport to the width your zoom level produces, then compare document.documentElement.scrollWidth against clientWidth. At 400% in a 1280 px window the viewport is 320 CSS px. The order page below reported a scrollWidth of 916 px inside a 320 px viewport, so reading it needed horizontal scrolling.
Why check this
Run this after any layout change, after a wide table or code block is added, and on staging sign-off for screens support agents keep magnified all day.
The defect it catches is a fixed-width element inside a responsive shell. The breakpoint fires, the page stacks, and one table still forces the document 596 px wider than the viewport. A user at 400% drags the page sideways to read every row, which is the two-dimensional scrolling WCAG 1.4.10 exists to prevent.
Three operations share one word
Full-page browser zoom divides the CSS viewport by the zoom factor. A narrow device viewport gives the same CSS width but applies the viewport meta tag, so a page pinned to 1024 never reflows. Pinch zoom scales the visual viewport only.
Prerequisites
- Chrome 120 or later and Node 22.
npm i puppeteer-coreinstalls the driver only and uses the Chrome already on the machine. SetCHROMEif yours is elsewhere. - Understanding SC 1.4.10 Reflow states the 320 CSS px target and the 1280 px equivalence.
- A page with the defect on it, served locally. Save both files in one directory, run
node server.mjs 8947, and stop it by PID afterwards, never by image name.
// server.mjs
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const port = Number(process.argv[2] || 8947);
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript' };
createServer(async (req, res) => {
const path = req.url.split('?')[0];
const ext = path.slice(path.lastIndexOf('.'));
try {
const body = await readFile(new URL('.' + path, import.meta.url));
res.writeHead(200, { 'content-type': types[ext] ?? '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 + '/'));
<!-- orders.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1024">
<title>Orders</title>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 0; padding: 16px; }
.layout { display: flex; gap: 24px; }
.sidebar { width: 240px; flex: none; }
.main { flex: 1; min-width: 0; }
table { border-collapse: collapse; min-width: 900px; }
th, td { border: 1px solid #ccc; padding: 6px 10px; white-space: nowrap; }
@media (max-width: 700px) {
.layout { display: block; }
.sidebar { width: auto; }
}
</style>
</head>
<body>
<h1>Orders</h1>
<div class="layout">
<nav class="sidebar"><ul><li>All</li><li>Open</li><li>Shipped</li></ul></nav>
<div class="main">
<div class="table-wrap">
<table id="orders">
<tr><th>Order</th><th>Customer</th><th>Placed</th><th>Status</th><th>Carrier</th><th>Tracking</th><th>Total</th></tr>
<tr><td>A-10241</td><td>Ilse Brenner</td><td>2026-09-02</td><td>Shipped</td><td>DHL Express</td><td>JD014600001234</td><td>148.20</td></tr>
<tr><td>A-10242</td><td>Tomas Ruiz</td><td>2026-09-03</td><td>Open</td><td>PostNL</td><td>3SABCD1234567</td><td>62.00</td></tr>
<tr><td>A-10243</td><td>Hanne Vos</td><td>2026-09-04</td><td>Shipped</td><td>DHL Express</td><td>JD014600007788</td><td>311.45</td></tr>
</table>
</div>
</div>
</div>
</body>
</html>
- Two scripts, same directory.
// reflow.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const url = process.argv[2];
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
const measure = () => page.evaluate(() => {
const d = document.documentElement;
return {
innerWidth, layoutWidth: d.clientWidth, scrollWidth: d.scrollWidth,
visual: Math.round(visualViewport.width) + ' @ scale ' + visualViewport.scale,
narrowRule: matchMedia('(max-width: 700px)').matches, dpr: devicePixelRatio,
};
});
const show = (label, m) => console.log(
label.padEnd(26), ('innerWidth ' + m.innerWidth).padEnd(17), ('layout ' + m.layoutWidth).padEnd(12),
('scroll ' + m.scrollWidth).padEnd(13), ('visual ' + m.visual).padEnd(22),
'max-width:700px ' + m.narrowRule, ' dpr ' + m.dpr);
const metrics = (o) => cdp.send('Emulation.setDeviceMetricsOverride', o);
await metrics({ width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
await page.goto(url, { waitUntil: 'networkidle2' });
show('1280 px, 100%', await measure());
await metrics({ width: 640, height: 400, deviceScaleFactor: 2, mobile: false });
show('1280 px, 200% zoom', await measure());
await metrics({ width: 320, height: 200, deviceScaleFactor: 4, mobile: false });
show('1280 px, 400% zoom', await measure());
await metrics({ width: 320, height: 640, deviceScaleFactor: 2, mobile: true });
await page.reload({ waitUntil: 'networkidle2' });
show('320 px phone viewport', await measure());
await metrics({ width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
await page.reload({ waitUntil: 'networkidle2' });
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 4 });
show('1280 px, pinch zoom 4x', await measure());
await browser.close();
// overflow.mjs
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const browser = await launch({ executablePath: CHROME, headless: true });
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await cdp.send('Emulation.setDeviceMetricsOverride', {
width: 320, height: 200, deviceScaleFactor: 4, mobile: false,
});
await page.goto(process.argv[2], { waitUntil: 'networkidle2' });
const out = await page.evaluate(() => {
const limit = document.documentElement.clientWidth;
const rows = [];
for (const el of document.querySelectorAll('body *')) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.right <= limit + 1) continue;
// Report the outermost offender only: skip an element whose parent also overflows.
const p = el.parentElement;
if (p && p !== document.body && p.getBoundingClientRect().right > limit + 1) continue;
rows.push(
`${el.tagName.toLowerCase()}${el.id ? '#' + el.id : ''}`.padEnd(15) +
`right ${Math.round(r.right)}`.padEnd(15) +
`width ${Math.round(r.width)}`.padEnd(15) +
`min-width ${getComputedStyle(el).minWidth}`
);
}
return { limit, scrollWidth: document.documentElement.scrollWidth, rows };
});
console.log(`viewport ${out.limit} px document scrollWidth ${out.scrollWidth} px horizontal scrolling: ${out.scrollWidth > out.limit}`);
for (const r of out.rows) console.log(' ' + r);
await browser.close();
- Every figure below is one capture on one machine, Chrome 152.0.7977.76, 2026-09-11. Layout widths move with the font stack and the Chrome build.
Steps
- Step 1.
Measure the same page under every operation people call zoom.
node reflow.mjs http://127.0.0.1:8947/orders.html1280 px, 100% innerWidth 1280 layout 1280 scroll 1280 visual 1280 @ scale 1 max-width:700px false dpr 1 1280 px, 200% zoom innerWidth 640 layout 640 scroll 916 visual 640 @ scale 1 max-width:700px true dpr 2 1280 px, 400% zoom innerWidth 320 layout 320 scroll 916 visual 320 @ scale 1 max-width:700px true dpr 4 320 px phone viewport innerWidth 1180 layout 1024 scroll 1180 visual 1180 @ scale 0.2711864411830902 max-width:700px false dpr 2 1280 px, pinch zoom 4x innerWidth 1280 layout 1280 scroll 1280 visual 320 @ scale 4 max-width:700px false dpr 1Read the rows against each other. Browser zoom divides the CSS viewport: 200% gives 640, 400% gives 320, and
max-width: 700pxfires in both. The zoom factor arrives as the device pixel ratio, which is whydpr 4sits besideinnerWidth 320. Scroll width stays 916 px at both.Rows three and four are both 320 CSS px of device width, and they differ. On the phone row the viewport meta tag pinned the layout viewport to 1024 px, the narrow rule never matched, and Chrome shrank the page to scale 0.27. On the pinch row the layout viewport stayed at 1280 while the visual viewport narrowed to 320. Only the zoom rows test 1.4.10.
- Step 2.
Name what actually sticks out at 320 CSS px, rather than reading a scrollbar.
node overflow.mjs http://127.0.0.1:8947/orders.htmlviewport 320 px document scrollWidth 916 px horizontal scrolling: true table#orders right 916 width 900 min-width 900pxOne element, and it carries its own cause. The
min-width: 900pxbeats every responsive rule around it. The fix is a scrolling wrapper on the table, which the criterion allows, or a stacked layout under the breakpoint. - Step 3.
Run the same reading against a page nobody wrote for this exercise.
node overflow.mjs https://developer.mozilla.org/en-US/docs/Web/HTTPviewport 320 px document scrollWidth 305 px horizontal scrolling: falseNothing crosses the edge. That is the shape of a pass: no offender lines, and a scroll width no wider than the viewport.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| scrollWidth above clientWidth at 320 px | Content needs scrolling in two directions | Run step 3, fix the named element, retest |
| An offender with a min-width in px | A fixed width survived the breakpoint | Wrap it in an overflow-x: auto container or restructure it |
| max-width:700px false at a narrow width | The viewport meta tag pinned the layout viewport | Read How to check viewport meta tag; a fixed width= value blocks reflow |
| visual far below innerWidth | Pinch zoom, not browser zoom | Reset the page scale factor and use the device metrics override instead |
| Scroll width at or below the viewport, no offenders | Reflow holds at this width | Repeat at 200% and at 400%, since a breakpoint can pass one and fail the other |
Common mistakes
Thresholds
Content for vertical scrolling must work at a viewport 320 CSS px wide without scrolling in two directions. The same document gives the equivalence used in step 1: 320 CSS px is a 1280 px viewport at 400% zoom.
Source: W3C, Understanding SC 1.4.10 Reflow, https://www.w3.org/WAI/WCAG22/Understanding/reflow.htmlText must stay readable and functional when resized to 200% without assistive technology. On the capture above that level put the viewport at 640 CSS px, where the table already overflowed.
Source: W3C, Understanding SC 1.4.4 Resize Text, https://www.w3.org/WAI/WCAG22/Understanding/resize-text.htmlWhat to check next
- How to check viewport meta tag:
user-scalable=noand a lowmaximum-scaleblock zoom outright. - How to find css breakpoints of a website: the widths that change the layout, so you test the right ones.
- How to check tap target size: what changes when a layout reflows to one column.
- How to test prefers reduced motion: the same emulation on a media feature rather than a width.
- How to test keyboard navigation on a website: the reflowed layout has a new focus order.
FAQ
How do I test WCAG reflow?
Put the CSS viewport at 320 px wide and check that document.documentElement.scrollWidth does not exceed clientWidth. Read the page at that width and confirm no content or control disappeared. Step 2 reports the verdict and the element responsible.
Is testing at 200% the same as testing 1.4.10?
No. SC 1.4.4 asks for readable text at 200%. SC 1.4.10 asks for no two-dimensional scrolling at 320 CSS px, which is 400% in a 1280 px window. On the capture above, 200% put the viewport at 640 px, where the table still overflowed.
Why does device emulation give a different result from browser zoom?
Device emulation turns on mobile mode, and mobile mode honours the viewport meta tag. The order page pins its layout viewport to 1024 px, so at 320 px of device width Chrome reported innerWidth 1180 and scaled the page to 0.27.
Does a table with horizontal scrolling fail 1.4.10?
Not by itself. The criterion permits two-dimensional scrolling for content that requires it, and data tables are the named example. The failure in step 2 is that the table pushes the whole document wider, so the page scrolls sideways, not the table alone.
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