skills/hubeiqiao/skills/playwright-whileinview-screenshots

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", 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:

// 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.$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 });
// 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: 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
Weekly Installs
1
First Seen
7 days ago