page.screenshot({ fullPage: true }) on an app whose scrolling happens inside a <main> element gives you one viewport. The Playwright docs say it takes "a screenshot of the full scrollable page", and that is the document's scroll height, not the height of any inner scroller. Dashboards, docs sites and many SPA shells look like this: body is exactly the viewport and the content scrolls in a child.

I wanted to know what actually works, and what each fix breaks. I tested three things on a local fixture server: the app-shell case, two fixes, and whether very tall pages hit a size limit. Setup: Playwright 1.63.0 with Chromium 153.0.8010.12 (headless shell), Node v24.22.2, Linux 6.8, on 2026-11-07. Pixel checks use @napi-rs/canvas 1.1.0 to decode the PNG.

The failure

The fixture is a six-band page (500 px each, a different hue per band) inside a <main> that scrolls. html and body are height:100%; overflow:hidden.

// shell.cjs const http = require('http'); const { chromium } = require('playwright'); const { createCanvas, loadImage } = require('@napi-rs/canvas'); // App shell: <body> is exactly the viewport, the content scrolls inside <main>. const bands = [0, 1, 2, 3, 4, 5].map(i => `<section style="height:500px;background:hsl(${i * 60},70%,50%)"></section>`).join(''); const html = `<!doctype html><style> html,body{margin:0;height:100%;overflow:hidden} main{height:100%;overflow:auto}</style><main>${bands}</main>`; async function lastBandPixel(buf) { const img = await loadImage(buf); const c = createCanvas(img.width, img.height), g = c.getContext('2d'); g.drawImage(img, 0, 0); return { size: `${img.width}x${img.height}`, px: Array.from(g.getImageData(10, img.height - 100, 1, 1).data.slice(0, 3)).join(',') }; } (async () => { const srv = http.createServer((_, res) => { res.setHeader('content-type', 'text/html'); res.end(html); }); await new Promise(r => srv.listen(0, '127.0.0.1', r)); const url = `http://127.0.0.1:${srv.address().port}/`; const browser = await chromium.launch(); const open = async () => { const ctx = await browser.newContext({ viewport: { width: 400, height: 800 } }); const page = await ctx.newPage(); await page.goto(url); return page; }; let page = await open(); console.log('fullPage:true ', await lastBandPixel(await page.screenshot({ fullPage: true })), '| document.scrollHeight', await page.evaluate(() => document.documentElement.scrollHeight), '| main.scrollHeight', await page.evaluate(() => document.querySelector('main').scrollHeight)); page = await open(); const h = await page.evaluate(() => document.querySelector('main').scrollHeight); await page.setViewportSize({ width: 400, height: h }); console.log('A: resize viewport ', await lastBandPixel(await page.screenshot())); page = await open(); await page.addStyleTag({ content: 'html,body,main{height:auto!important;overflow:visible!important}' }); console.log('B: override CSS ', await lastBandPixel(await page.screenshot({ fullPage: true }))); console.log('expected last band ', 'hsl(300,70%,50%) = 217,38,217'); await browser.close(); srv.close(); })(); 

Output:

fullPage:true { size: '400x800', px: '217,217,38' } | document.scrollHeight 800 | main.scrollHeight 3000 A: resize viewport { size: '400x3000', px: '217,38,217' } B: override CSS { size: '400x3000', px: '217,38,217' } expected last band hsl(300,70%,50%) = 217,38,217 

fullPage: true returned 400x800, and the pixel near the bottom is the second band. The document is 800 px tall; the 3000 px is in main. Playwright cannot know which element you consider "the page".

Two fixes both gave the expected 400x3000 image with the last band's colour at the bottom:

  • A: resize the viewport to the container's content height, then take a normal screenshot.
  • B: override the CSS (height:auto; overflow:visible on html, body and the container) and then use fullPage: true.

B needs to know your element names. A can be written once and generic. But A changes what the page believes about its window, and that matters.

What resizing the viewport changes

In Playwright 1.63.0's source (playwright-core/lib/coreBundle.js, takeScreenshot), a full-page shot calls the DevTools Page.captureScreenshot with captureBeyondViewport and a clip. The viewport is not resized, so 100vh stays 800 px in a normal full-page shot. Fix A does resize it, and the page sees that.

Below is a generic helper for fix A. It finds the largest inner scroller, compares it with the document's own overflow, falls back to plain fullPage: true for ordinary pages, and restores the viewport afterwards. The rest of the script checks three side effects.

// helper.cjs const http = require('http'); const { chromium } = require('playwright'); // Resize handlers run on the next frame, so wait two frames before reading the page. const settle = page => page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))); // Full-page screenshot that also works when the page scrolls inside a container. async function fullPageShot(page) { const vp = page.viewportSize(); // How many pixels does the document scroll, and how many does the biggest inner scroller? const extra = await page.evaluate(() => { let inner = 0; for (const el of document.querySelectorAll('body *')) { const oy = getComputedStyle(el).overflowY; if (oy === 'auto' || oy === 'scroll') inner = Math.max(inner, el.scrollHeight - el.clientHeight); } return { doc: document.documentElement.scrollHeight - innerHeight, inner }; }); if (extra.inner <= extra.doc) return page.screenshot({ fullPage: true }); // ordinary page await page.setViewportSize({ width: vp.width, height: vp.height + extra.inner }); await settle(page); try { return await page.screenshot(); } finally { await page.setViewportSize(vp); } } const base = `<!doctype html><style>html,body{margin:0;height:100%;overflow:hidden} main{height:100%;overflow:auto;position:relative}</style>`; const pages = { doc: `<!doctype html><body style="margin:0"><div style="height:2500px;background:#468"></div>`, shell: base + `<main><div style="height:3000px;background:#468"></div></main>`, hero: base + `<main><div id=hero style="height:100vh;background:#08f"></div><div style="height:2500px"></div></main>`, virtual: base + `<main id=m><div style="height:3000px"></div></main><script> const m = document.getElementById('m'); function render() { m.querySelectorAll('.row').forEach(r => r.remove()); const first = Math.floor(m.scrollTop / 30), count = Math.ceil(m.clientHeight / 30) + 1; for (let i = first; i < Math.min(100, first + count); i++) { const r = document.createElement('div'); r.className = 'row'; r.textContent = 'row ' + i; r.style.cssText = 'position:absolute;left:0;right:0;height:30px;top:' + i * 30 + 'px'; m.appendChild(r); } } m.addEventListener('scroll', render); addEventListener('resize', render); render(); </script>`, }; const pngHeight = buf => buf.readUInt32BE(20); // IHDR height (async () => { const srv = http.createServer((req, res) => { res.setHeader('content-type', 'text/html'); res.end(pages[req.url.slice(1)] || ''); }); await new Promise(r => srv.listen(0, '127.0.0.1', r)); const browser = await chromium.launch(); const open = async name => { const ctx = await browser.newContext({ viewport: { width: 400, height: 800 } }); const page = await ctx.newPage(); await page.goto(`http://127.0.0.1:${srv.address().port}/${name}`); return page; }; for (const name of ['doc', 'shell']) { const page = await open(name); const buf = await fullPageShot(page); console.log(`${name}: png height ${pngHeight(buf)}, viewport restored to`, JSON.stringify(page.viewportSize())); } const page = await open('hero'); const heroHeight = () => page.evaluate(() => document.getElementById('hero').getBoundingClientRect().height); console.log('hero (100vh): normal', await heroHeight()); await page.setViewportSize({ width: 400, height: 3300 }); console.log('hero (100vh): viewport 3300px', await heroHeight()); let settled = 0; for (let i = 0; i < 20; i++) { const list = await open('virtual'); const rows = () => list.evaluate(() => document.querySelectorAll('.row').length); await list.setViewportSize({ width: 400, height: 3000 }); await settle(list); if (await rows() === 100) settled++; } console.log(`virtual list, viewport 3000px, 100 rows expected, after two frames: ${settled}/20`); await browser.close(); srv.close(); })(); 

Output:

doc: png height 2500, viewport restored to {"width":400,"height":800} shell: png height 3000, viewport restored to {"width":400,"height":800} hero (100vh): normal 800 hero (100vh): viewport 3300px 3300 virtual list, viewport 3000px, 100 rows expected, after two frames: 20/20 

What this shows:

  1. 100vh stretches. A hero section with height:100vh was 800 px and became 3300 px, the whole page. If your layout has a full-height hero, fix A makes it huge. Fix B leaves 100vh alone in the same fixture (I measured it at 800 px with the override applied). Pick B when the page uses vh units; pick A when it does not.
  2. Windowed lists re-render, but not instantly. The virtual page renders only the rows that fit its container (28 of 100 rows at 800 px). After the resize it renders all 100, but only once its resize handler has run on the next frame. In the first version of the helper I read the page straight after setViewportSize(). In 6 separate runs, 3 still showed 28 rows; in a later 20-iteration loop 19 of 20 were fine, so the race is real but not constant. A wait of two animation frames fixed it: 20 of 20 iterations had all 100 rows. This is why settle() exists. If your app loads data on resize, two frames will not be enough and you need to wait for your own readiness signal.
  3. Fix B does not trigger resize handlers. With the CSS override on the same windowed list, the DOM stayed at 28 rows until I dispatched a resize event by hand (then 100). B is the right tool for static layouts; for windowed lists A is easier.

Limits of this helper: it picks one scroller (the largest), so a page with a long sidebar and a long main area gets sized to whichever is longer and the other may stay cropped. I only tested the fixtures above, not real applications. In a separate run on an ordinary page with a position:fixed header, the full-page PNG had the header at the top and plain page content at the pixels I sampled further down (y=1200 and y=3500), so fixed elements were not repeated down the image.

Do tall pages hit a size limit?

I expected some size limit to show up and went looking for one. I could not reproduce a failure. The next script builds pages of up to 200,000 CSS px and checks the colour at the middle of every 1000 px band in the resulting PNG.

// tall.cjs const http = require('http'); const { chromium } = require('playwright'); const { createCanvas, loadImage } = require('@napi-rs/canvas'); // A page of 1000px bands with an exact total height; band i has hue i*37. const page = h => { const n = Math.floor(h / 1000), rest = h - n * 1000; let b = ''; for (let i = 0; i < n; i++) b += `<div style="height:1000px;background:hsl(${(i * 37) % 360},70%,50%)"></div>`; if (rest) b += `<div style="height:${rest}px;background:hsl(${(n * 37) % 360},70%,50%)"></div>`; return `<!doctype html><body style="margin:0">${b}`; }; const hsl = (h, s, l) => { // CSS hsl() -> rgb s /= 100; l /= 100; const k = n => (n + h / 30) % 12, a = s * Math.min(l, 1 - l); return [0, 8, 4].map(n => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))))); }; (async () => { const srv = http.createServer((req, res) => { res.setHeader('content-type', 'text/html'); res.end(page(+new URL(req.url, 'http://x').searchParams.get('h'))); }); await new Promise(r => srv.listen(0, '127.0.0.1', r)); const browser = await chromium.launch(); for (const [dsf, h] of [[1, 16385], [1, 100000], [1, 200000], [2, 32768], [2, 65536]]) { const ctx = await browser.newContext({ viewport: { width: 400, height: 800 }, deviceScaleFactor: dsf }); const p = await ctx.newPage(); await p.goto(`http://127.0.0.1:${srv.address().port}/?h=${h}`); const img = await loadImage(await p.screenshot({ fullPage: true })); const c = createCanvas(img.width, img.height), g = c.getContext('2d'); g.drawImage(img, 0, 0); let wrong = 0; const n = Math.ceil(h / 1000); for (let iSource:https://dev.to/curlshot/playwright-fullpage-screenshots-return-one-viewport-when-the-page-scrolls-inside-a-container-2nm5

Related posts