# Playwright Whileinview Screenshots

> 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.

- Skill: `hubeiqiao/playwright-whileinview-screenshots` (Agent Skill)
- Install (CLI): `npx skillmds@latest add hubeiqiao/playwright-whileinview-screenshots`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hubeiqiao/playwright-whileinview-screenshots/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: hubeiqiao (https://skillmd.com/u/hubeiqiao)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/hubeiqiao/playwright-whileinview-screenshots

---


# 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:**

```typescript
// 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):**
```typescript
// 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):**
```typescript
await page.goto('http://localhost:3000');
await page.screenshot({ path: 'full.png', fullPage: true });
// Result: Most sections blank (opacity: 0)
```

**After (working):**
```typescript
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

