Automated tests that catch horizontal overflow — a page that ends up wider than the screen showing it, so the whole thing scrolls sideways.
You give it a list of routes. It loads each one in a real browser at every screen width that matters, measures whether anything crosses the right edge of the viewport, and fails with the element responsible.
✘ no horizontal overflow — /about @ md-768 (768px)
Horizontal overflow at /about @ md-768 (768px):
scrollWidth 812px > clientWidth 768px (44px over, tolerance 1px).
Worst offending elements:
div#hero.grid.grid-cols-3 — right edge: 812px, width: 812px
Setup is one config file. Runs in seconds, in npm test or CI.
Horizontal overflow is the most common responsive bug there is, and one of the
hardest to notice. A single element too wide — an image without a max-width, a
table, a long unbroken URL, a grid that doesn't wrap, a 100vw block inside
padding — drags the entire page with it. Nothing errors. It renders fine on a
desktop monitor, so it survives development and code review, and usually
surfaces after release.
It also isn't only cosmetic:
- Mobile is the version Google indexes. Under mobile-first indexing, the mobile rendering is what gets crawled and evaluated.
- It's an accessibility failure. WCAG 2.1 criterion 1.4.10 (Reflow, level AA) requires content to reflow to a 320px width without horizontal scrolling.
- It costs conversions. A submit button sitting off the right edge is not a styling nit.
Checking it by hand means opening every page at a dozen widths after every change, which nobody sustains. This makes it a test: no screenshots, no baselines, no diffs to approve — it passes, or it names the element to fix.
An agent editing layout code can't see the result. It has no way to know a change pushed something past the viewport, so it reports the work as done and the break ships.
The less obvious problem is what it does when you ask it to check. It writes a Playwright script — picking its own viewports, its own tolerance, its own guess at which pages matter — runs it, and throws it away. Next session it writes a different one. Across a handful of projects you end up with a handful of incompatible ad-hoc harnesses and no baseline anywhere: nothing is a regression, because nothing was measured the same way twice. A page checked at 375px last week is checked at 390px today, and the width that actually broke gets tested by nobody.
The agent is going to build this anyway. This is the version it would have written, settled in advance — the same viewport coverage in every project from the first commit, in a config a human can read and change.
Cost is the other half, and it's why the tiers exist. light is six viewports
and reports a line or two of text, so an agent can run it after small edits
without it being a decision — no screenshots to feed back through a model, no
context burned re-deriving the harness. medium widens the net for a real
layout change, full belongs in CI. One tool, scaled by the size of the edit
rather than swapped out for a bigger one.
Step 7 wires this into your agent's own instructions in one paste.
It drives a browser over HTTP, so it works against Next, Astro, Vite, SvelteKit, Nuxt, Remix, Laravel, Rails, Django, WordPress, or a folder of static HTML. It never reads or touches your source — only the rendered page.
Tailwind users get a head start: the default viewports are Tailwind v4's breakpoints. If you don't use Tailwind, replace them with your own in one config key.
Visual regression tools compare pixels, so someone — a human or a model — has to judge whether a diff is a real break or just noise: font rendering, animation timing, a new blog title. This does neither.
At each viewport it reads document.documentElement.scrollWidth against
clientWidth inside the page. Wider than the viewport means something
overflowed. Full stop, no judgment call. No baseline images to maintain, no
review step, no flaky pixel diffs.
When it fails it walks the DOM for the elements whose bounding boxes actually cross the right edge and names the worst offenders, so you aren't bisecting the page by hand.
It also catches the case that measurement alone misses: when an ancestor has
overflow-x: hidden, content wider than the page is clipped instead of
scrolled, so scrollWidth never moves and the page passes while text is cut
off. Those elements are compared against the box of whatever is clipping them —
see Clipped overflow.
- Node 18+ — the tests run on Node even if your app doesn't.
- A site you can serve locally over HTTP (any stack).
npm install --save-dev responsive-overflow-tests @playwright/test@playwright/test is a peer dependency, so install it alongside.
Playwright ships the runner; the browser binary is a separate download.
npx playwright install chromiumnpx responsive-overflow-tests initThat creates three files and adds one line to your .gitignore:
| File | You edit it? | What it is |
|---|---|---|
responsive-overflow-tests.config.ts |
Yes — this one | Your port, routes, everything |
playwright.config.ts |
No | Three-line shim that reads the config above |
e2e/overflow.spec.ts |
No | Generated test stub |
.gitignore |
— | Gains .playwright/ |
Nothing else is written, and existing files are never overwritten.
Not using TypeScript? init detects that and scaffolds .js/.mjs instead.
Already have a Playwright suite?
initsees yourplaywright.config.*, leaves it alone, and prints how to connect the two. Your config keeps owningbaseURL,webServer, projects and workers; this one contributes routes and viewports only. Delete the generated shim and follow Using an existing Playwright config — there's one Playwright gotcha there worth reading before you run it.
Gitignore. Every artifact a run produces — traces, error context, the cached login session — goes to
.playwright/, and nothing is written outside it.initadds that one line to your.gitignorefor you. Worth confirming it landed, particularly if your ignore rules live somewhere non-standard (a global gitignore,.git/info/exclude, or a monorepo root):.playwright/
Open responsive-overflow-tests.config.ts. To get a first run, there are three
things to set — where your site runs, how to start it, and what to check:
import { defineConfig } from "responsive-overflow-tests";
export default defineConfig({
// The port to serve the site on for tests.
// Framework defaults: Next 3000 · Astro 4321 · Vite/SvelteKit 5173 · Laravel 8000
port: 3000,
// How to boot it. Delete this line if you start the server yourself.
// Laravel: "php artisan serve"
// PHP: "php -S localhost:8000 -t public"
// Static: "npx http-server ./dist -p 8080"
startCommand: "npm run dev",
// The routes to check.
routes: {
light: ["/", "/about", "/contact"],
medium: [],
full: [],
},
});You don't need every route on your site. Put the pages whose layout actually
matters in light, and let the other tiers grow over time.
Give the tests their own port. The obvious choice is your framework's default, and it's the one that bites. Locally the runner attaches to an already-running server rather than booting one (
reuseExistingServer), so if your ownnpm run devis sitting on that port, the tests silently check that — possibly a different branch, possibly a stale build — and you get a green run against the wrong site. Pick a port you only use for tests, set it here and instartCommand, and the two can never collide.
That's the minimum. The same file also holds authentication, custom viewports, ignored selectors, timeouts, and tier defaults — see the full config reference. You will not need a second config file for any of it.
npx playwright testPlaywright boots your server, checks every route at every viewport in the active tier, and shuts down. One test per route × viewport.
Testing a page that charges a card, sends mail, or writes to a database? These are real page loads in a real browser, and a checkout route will do checkout things. Read Routes that do something before you add one — especially if this will ever run in CI.
{
"scripts": {
"test:responsive": "playwright test",
"test": "vitest run && npm run test:responsive"
}
}If you have other Playwright suites, narrow this one with
playwright test -g "horizontal overflow" — every generated test title contains
that phrase — or with --project= if you gave it its own project.
If you work with an AI coding agent, it won't discover any of this on its own. Paste this to it once:
Read
node_modules/responsive-overflow-tests/ADVANCED.md, find the "AI agents & automated workflows" section, and add its guidance block to this project'sAGENTS.md(orCLAUDE.mdif that's what we use).
ADVANCED.md ships inside the package, so this needs no network access. The
agent ends up with the rules written into your project's own instructions: which
tier to run when, that a failure means fixing the element rather than raising
tolerancePx, and to add new routes to the config.
See AI agents and automated workflows for the block itself and the reasoning behind the tiers.
One knob: tier. It scales both the viewports and the routes, and the tiers
are cumulative — medium runs everything in light too.
| Tier | Viewports | When to run it |
|---|---|---|
light (default) |
6 — every Tailwind v4 breakpoint plus a small-phone width | Every commit |
medium |
+9 — one px below each breakpoint, plus real phone/desktop widths including 320px | Significant layout changes, before merging |
full |
+3 — fold, QHD, ultrawide | CI, pre-release |
Override per run:
RESPONSIVE_TIER=full npx playwright testIf WCAG reflow compliance is what you're after, medium is your floor — 320px,
the width criterion 1.4.10 is written against, lives in that tier.
Tailwind breakpoints are min-width, so md: engages at 768. At exactly 768
you get the layout you designed and looked at. 767 is where the smaller
layout is still active and stretched to its widest — which is where things
actually burst, and the width nobody ever eyeballs. medium covers both sides
of every breakpoint for that reason.
All of it lives in the same single config file — see ADVANCED.md:
- Testing pages behind a login
- Routes that do something — payments, mail, side effects
- Custom viewports and resolutions
- Ignoring embeds you can't fix
- Continuous integration and git hooks
- AI agents and automated workflows
- Troubleshooting
- Full config reference
- Using an existing Playwright config
- Writing your own spec
Upgrading from an earlier version? See the changelog — 0.3.0 replaced the old API.
- It does not check vertical overflow, visual styling, or content.
- It does not replace visual review — it proves the layout didn't physically break, not that it looks right.
- It does not need or produce baseline images.
- It does not check pages it isn't told about — routes are an explicit list, not a crawl.
This is the deterministic half. It will never tell you that two elements overlap, that a heading wrapped badly, or that a section is unreadable at 390px — all of which fit inside the viewport and pass here.
For that, run a screenshot pass and actually look at the output. If you use
Claude Code, frontend-screenshot-verification
is a plugin that renders a route across a tiered matrix of real device viewports
so an agent can review them. Any screenshot tool works — the point is that
something has to exercise judgment.
The two overlap slightly: that plugin also flags horizontal overflow, since it's
free once the page is loaded. Treat this package as the authoritative one — it
names the offending element, returns a non-zero exit code, and costs nothing to
run, so it's the one that belongs in npm test and CI. The screenshot pass is
for the questions no mechanical check can answer.
MIT © CyberPunk