playwright-whileinview-screenshots
Installation
SKILL.md
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", orvariantswith viewport triggers - Content exists in the DOM (verified via
page.$()) butisVisible()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-timelineanimations
Solution
Before taking screenshots, scroll through the entire page to trigger all animations:
// Step 1: Wait for page load
await page.goto(url, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Step 2: Scroll through entire page to trigger IntersectionObserver
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); // Allow observer callbacks to fire
}
// Step 3: Wait for animations to complete
await page.waitForTimeout(1500);
// Step 4: Now take screenshots (per-section is more reliable than fullPage)
const section = await page.$('section[aria-labelledby="my-heading"]');
if (section) {
await section.scrollIntoViewIfNeeded();
await page.waitForTimeout(800); // Wait for entrance animation
await page.screenshot({ path: 'section.png' });
}
For per-section screenshots (more reliable):
// Scroll to each section individually and capture
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.$evalto 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 });
// Result: Most sections blank (opacity: 0)
After (working):
await page.goto('http://localhost:3000', { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Trigger all scroll animations
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);
// Capture individual sections
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: truestill 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-testidattributes to sections for reliable selector targeting in tests