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

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

Steps

  1. Step 1.

    Measure the same page under every operation people call zoom.

    node reflow.mjs http://127.0.0.1:8947/orders.html
    
    1280 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 1

    Read the rows against each other. Browser zoom divides the CSS viewport: 200% gives 640, 400% gives 320, and max-width: 700px fires in both. The zoom factor arrives as the device pixel ratio, which is why dpr 4 sits beside innerWidth 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.

  2. 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.html
    
    viewport 320 px   document scrollWidth 916 px   horizontal scrolling: true
    table#orders   right 916      width 900      min-width 900px

    One element, and it carries its own cause. The min-width: 900px beats 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.

  3. 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/HTTP
    
    viewport 320 px   document scrollWidth 305 px   horizontal scrolling: false

    Nothing 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

Sign: A device emulation preset at 320 px is used as the 400% zoom test.Cause: Device emulation sets mobile mode, so the viewport meta tag applies. On the capture above the same 320 px of device width produced a 1024 px layout viewport, no breakpoint match and a page scaled to 0.27. Browser zoom ignores the meta tag entirely.
Sign: Pinch zoom on a laptop trackpad is reported as a reflow test.Cause: Pinch zoom changes the page scale factor. The capture shows innerWidth still 1280 and the visual viewport at 320. No media query re-evaluated, so the layout under the magnifier is the desktop layout, and the result says nothing about 1.4.10.
Sign: The absence of a horizontal scrollbar is read as a pass.Cause: A scrollbar can be hidden by overflow rules on an ancestor while the content is still clipped, and a shrunk-to-fit page has no scrollbar and no readable text. Measure scrollWidth against clientWidth and list the offenders, which is what step 3 does.

Thresholds

320 CSS px

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.html
200%

Text 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.html

What to check next

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.

intermediate10 minpublished updated Maks Verny