| name | playwright-whileinview-screenshots |
| description | Fix invisible/blank sections in Playwright full-page screenshots when using Framer Motion
whileInView animations. Use when: (1) Playwright fullPage screenshots show blank areas where
content should be, (2) sections render with opacity:0 because IntersectionObserver never fires,
(3) motion elements with initial="hidden" appear missing in automated screenshots. Solution:
scroll through the page programmatically before capturing. Covers Framer Motion, GSAP ScrollTrigger,
and any IntersectionObserver-based reveal animations.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-01-29T00:00:00.000Z" |
Playwright Screenshots with Scroll-Triggered Animations
Problem
When taking full-page screenshots with Playwright, sections using Framer Motion's whileInView
(or any IntersectionObserver-based animation) appear blank/invisible. Elements start with
initial="hidden" (opacity: 0, y: 40, etc.) and never animate to visible because Playwright's
fullPage: true screenshot captures the DOM without triggering scroll-based intersection events.
Context / Trigger Conditions
- Playwright
page.screenshot({ fullPage: true }) produces images with blank sections
- Elements use Framer Motion
whileInView, initial="hidden", or variants with viewport triggers
- Content exists in the DOM (verified via
page.$()) but isVisible() may return true while opacity is 0
- Sections appear fine in a real browser when scrolled manually
- Also affects GSAP ScrollTrigger, vanilla IntersectionObserver, and CSS
scroll-timeline animations
Solution
Before taking screenshots, scroll through the entire page to trigger all animations:
await page.goto(url, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
const totalHeight = await page.evaluate(() => document.body.scrollHeight);
for (let y = 0; y < totalHeight; y += 300) {
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y);
await page.waitForTimeout(100);
}
await page.waitForTimeout(1500);
const section = await page.$('section[aria-labelledby="my-heading"]');
if (section) {
await section.scrollIntoViewIfNeeded();
await page.waitForTimeout(800);
await page.screenshot({ path: 'section.png' });
}
For per-section screenshots (more reliable):
const sections = ['hero', 'features', 'footer'];
for (const id of sections) {
const el = await page.$(`#${id}`);
if (el) {
await el.scrollIntoViewIfNeeded();
await page.waitForTimeout(1000);
await page.screenshot({ path: `${id}.png` });
}
}
Verification
- Sections that were blank now show content in screenshots
- Compare against
page.$eval to confirm elements have non-zero opacity after scrolling
- Console should show no Framer Motion warnings about missing containers
Example
Before (broken):
await page.goto('http://localhost:3000');
await page.screenshot({ path: 'full.png', fullPage: true });
After (working):
await page.goto('http://localhost:3000', { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
const height = await page.evaluate(() => document.body.scrollHeight);
for (let y = 0; y < height; y += 300) {
await page.evaluate((sy) => window.scrollTo(0, sy), y);
await page.waitForTimeout(100);
}
await page.waitForTimeout(1500);
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(500);
await page.screenshot({ path: 'hero.png' });
Notes
- Scroll step size of 300px works well; smaller values are slower but more thorough
- The 100ms wait per scroll step is necessary for IntersectionObserver callbacks to fire
fullPage: true still may not work perfectly after scrolling — per-section screenshots are more reliable
- For sticky/pinned sections (e.g., scroll-driven animations), you need to scroll to specific positions within the section's scroll runway
- This also applies when using
page.pdf() for PDF generation of animated pages
- Consider adding
data-testid attributes to sections for reliable selector targeting in tests