Why full-page screenshots of modern stores come out blank
Scroll-reveal sections and lazy images never paint in a one-shot full-page screenshot. Here's the screen-by-screen capture script I use instead.
- Playwright
- Tooling
- Screenshots
- Node.js
The projects on this site use screenshots of the live stores: homepage, PDP and cart drawer, desktop and mobile. When I first automated them with Playwright’s fullPage: true, most sections below the hero came back as white rectangles. The script that replaced it is on /resources. This is what it does and why.
Why one shot comes out blank
A full-page screenshot paints the whole document in one pass, without scrolling. Modern themes are built around scrolling:
- Sections start at
opacity: 0and animate in when an IntersectionObserver sees them. In a one-shot capture most never intersect, so they stay invisible. - Images use
loading="lazy"or a lazy loader keyed to scroll. Below the fold they’re still placeholders. - Some pages render sections only as you approach them, so the document is shorter at load than after a real scroll.
You get a header, a hero, a long gap and a footer. The browser context sets reducedMotion: 'reduce', which helps on themes that respect it. Many don’t.
Scroll one screen at a time, then stitch
The fix is to do what a visitor does: scroll one viewport, let the page react, take a screenshot, repeat. Before starting, every img[loading="lazy"] is switched to eager and smooth scrolling is turned off, because with scroll-behavior: smooth the call returns before the page has moved.
for (let i = 0; i < 30; i++) {
await page.evaluate((top) => window.scrollTo(0, top), y);
await page.waitForTimeout(500);
// Wait (briefly) for images now in view to finish loading
await page.evaluate(() => Promise.race([
Promise.all([...document.images].filter((img) => { const r = img.getBoundingClientRect(); return r.bottom > 0 && r.top < innerHeight && !img.complete; })
.map((img) => new Promise((res) => { img.addEventListener('load', res, { once: true }); img.addEventListener('error', res, { once: true }); }))),
new Promise((res) => setTimeout(res, 2000)),
]));
const actual = await page.evaluate(() => Math.round(window.scrollY));
if (shots.length) {
if (actual === shots[shots.length - 1].y) break; // scrolling is locked or we reached the end
// hide bars fixed to the top (next section)
}
shots.push({ buf: await page.screenshot(), y: actual });
// Pages that render sections as you scroll grow while we go, so re-measure every step
total = await page.evaluate((max) => Math.min(document.documentElement.scrollHeight, max), maxCss);
if (actual + vh >= total - 2) break;
y = actual + vh;
}
The image wait is a race against two seconds, so one broken image can’t hang the run. Each shot stores the real scrollY, not the requested one: the last scroll usually stops short, and the overlap has to land where it belongs. Then sharp composites the screens onto one canvas, scaled by the device pixel ratio (the mobile profile renders at 2x):
const height = Math.min(total, shots[shots.length - 1].y + vh);
return sharp({ create: { width: Math.round(vw * dpr), height: Math.round(height * dpr), channels: 3, background: '#ffffff' } })
.composite(shots.map((s) => ({ input: s.buf, left: 0, top: Math.round(s.y * dpr) })))
.png().toBuffer();
Fixed and sticky headers
Stitching brings its own problem: anything position: fixed shows up in every screen, so the header and announcement bar repeat down the page. After the first shot, the script marks fixed elements near the top (under 160px from the top, shorter than 240px) and hides them with visibility: hidden rather than display: none, so the layout doesn’t shift between screens.
Sticky taught me to be specific. My first version treated sticky like fixed and hid it after the first screen. On a Webflow site the whole hero vanished: it was a tall section whose content sat in a position: sticky wrapper driving a scroll animation. Now sticky elements aren’t hidden. They’re flattened into the normal flow:
for (const el of document.querySelectorAll('body *')) {
if (getComputedStyle(el).position === 'sticky') el.setAttribute('data-capture-unstick', '');
}
const style = document.createElement('style');
style.textContent = 'html{scroll-behavior:auto!important}[data-capture-stuck]{visibility:hidden!important}[data-capture-unstick]{position:relative!important;top:auto!important}';
document.head.appendChild(style);
A sticky header then appears once at the top like any other block, and a sticky section renders where it sits in the document. The markers come off when the capture ends.
Popups and scroll locks
Before the capture, settle() scrolls the page in 60% steps to wake lazy content, then hides leftover fixed overlays: anything covering more than a quarter of the viewport, or with chat, popup, modal, newsletter, cookie or consent in its class or id. Headers are exempt.
A hidden popup leaves its scroll lock behind. The obvious override, body { overflow: auto }, caused my worst bug in this script. On themes where the body has a fixed height, overflow: auto makes the body the scroll container. window.scrollTo does nothing, scrollY stays at 0, the loop sees no movement and stops, and the “full page” is one screen. The fix is to unlock html and leave the body alone:
// The body stays overflow:visible: forcing it to auto turns it into
// the scroller on themes where it has a fixed height, and then the window can't move.
style.textContent = '[data-capture-hidden]{display:none!important}html{overflow-y:auto!important}body{overflow:visible!important}';
Hiding is the second line of defense. The first is never loading the popups. Every browser context routes requests through a block list (Klaviyo, Attentive, Gorgias, OneTrust, Hotjar, tag managers and others) and aborts the matches. A popup blocked at the network level can’t appear after the cleanup ran, and pages reach network idle sooner.
Opening the cart drawer
I don’t fake the drawer by posting to /cart/add.js and toggling a class. Every theme opens its drawer differently, so openCart() clicks the store’s own Add to Cart and lets the theme run its real code path. The drawer then shows what a shopper sees, upsells and free-shipping bar included.
It tries a submit button inside form[action*="/cart/add"], then button[name="add"], then any button whose accessible name matches “Add to cart” and a few variants in English, Spanish and French. Two gotchas: settle() hid large fixed elements and the drawer is one, so the markers come off first; and chat bubbles and newsletter popups tend to return after the click, so they’re hidden again before the shot. For unusual themes, capture.cart in the project’s frontmatter accepts a CSS selector.
Takeaways
- Don’t trust
fullPage: trueon themes that reveal on scroll: scroll one viewport at a time, wait for in-view images, re-measure and stitch at the realscrollY. - Hide fixed top bars after the first screen, but flatten sticky elements into the flow instead of hiding them.
- Unlock scroll on
html, never make the body the scroller, block popup domains at the network level and open the drawer through the store’s own Add to Cart.