DEV Community

CurlShot
CurlShot

Posted on

Playwright fullPage screenshots return one viewport when the page scrolls inside a container

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();
})();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
})();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 i = 0; i < n; i++) { // sample the middle of every band
      const y = Math.min(img.height - 1, Math.floor((i * 1000 + Math.min(1000, h - i * 1000) / 2) * dsf));
      const d = g.getImageData(10, y, 1, 1).data, e = hsl((i * 37) % 360, 70, 50);
      if (Math.abs(d[0] - e[0]) + Math.abs(d[1] - e[1]) + Math.abs(d[2] - e[2]) > 6) wrong++;
    }
    console.log(`scale ${dsf}, page ${h} css px: png ${img.width}x${img.height}, wrong bands ${wrong}/${n}`);
    await ctx.close();
  }
  await browser.close(); srv.close();
})();
Enter fullscreen mode Exit fullscreen mode

Output:

scale 1, page 16385 css px: png 400x16385, wrong bands 0/17
scale 1, page 100000 css px: png 400x100000, wrong bands 0/100
scale 1, page 200000 css px: png 400x200000, wrong bands 0/200
scale 2, page 32768 css px: png 800x65536, wrong bands 0/33
scale 2, page 65536 css px: png 800x131072, wrong bands 0/66
Enter fullscreen mode Exit fullscreen mode

Every band came back right, up to a 131,072 px tall PNG. Time grew roughly linearly: in an earlier run of the same measurement, 200,000 CSS px at scale 1 took about 4 seconds and 65,536 CSS px at scale 2 about 5 seconds.

This does not show that no limit exists. The fixture is plain coloured divs, which is cheap to paint. A real page with images, shadows and filters could run out of memory long before this, and I did not test that. Nor did I test whether the programs that read your PNG (an image library, a browser tab, a PDF embedder) accept a 200,000 px image. The result is only that Playwright 1.63.0 with this Chromium build does not clip at 16384 on a simple page, so if you see a cropped screenshot, look at scroll containers first.

What I would use

  • Page scrolls normally: fullPage: true, nothing else.
  • Content scrolls in a container and the layout does not use vh: the helper (fix A) with a wait that matches your app.
  • The layout uses 100vh: fix B, aimed at your own selectors, plus a hand-dispatched resize if you have windowed lists.

Written with AI assistance. Every code sample was run on 2026-11-07 with Playwright 1.63.0 (Chromium 153.0.8010.12) on Node v24.22.2; the output above is from that run.

Top comments (0)