Claude
Skills
Sign in
Back

viewport-exposure-map

Included with Lifetime
$97 forever

Track element visibility during scroll using IntersectionObserver and generate an exposure heatmap showing which content users actually see.

Writing & Docs

What this skill does


# Viewport Exposure Map

Measure how long each significant content element stays visible in the viewport during a full scroll session. Identify CTAs that are never seen, content with minimal exposure, and layout regions that dominate user attention.

## When to Use

- Auditing landing page layout to ensure CTAs are visible without excessive scrolling
- Validating that key content (hero sections, pricing, signup forms) receives adequate viewport time
- Comparing two page variants for scroll-based content exposure
- Detecting elements pushed below the fold or hidden behind lazy-load failures
- QA for long-form content pages (blogs, documentation, product pages)

## Prerequisites

- Playwright MCP server connected with a browser session available
- Target page must be publicly accessible or already authenticated in the browser session
- Page should be fully loaded before starting (wait for network idle or a specific selector)

## Workflow

### Step 1: Navigate to the Target Page

Use `browser_navigate` to load the page.

```
browser_navigate({ url: "https://example.com/landing" })
```

Wait for the page to be fully loaded:

```
browser_wait_for({ text: "some expected text" })
```

Or wait a fixed time if no specific text anchor exists:

```
browser_wait_for({ time: 3 })
```

### Step 2: Inject IntersectionObserver Tracking

Use `browser_evaluate` to attach an IntersectionObserver to all significant content elements. This must run before any scrolling occurs.

```javascript
browser_evaluate({
  function: `() => {
    const SELECTORS = [
      'h1', 'h2', 'h3', 'h4',
      'p', 'img', 'section', 'article',
      'button', 'a',
      '[data-testid]',
      'figure', 'video',
      '.card', '.hero', '.cta',
      'form', 'nav', 'footer', 'header'
    ];

    const elements = new Set();
    SELECTORS.forEach(sel => {
      document.querySelectorAll(sel).forEach(el => elements.add(el));
    });

    window.__exposureData = new Map();
    let idCounter = 0;

    const observer = new IntersectionObserver((entries) => {
      const now = performance.now();
      entries.forEach(entry => {
        const id = entry.target.dataset.__exposureId;
        const data = window.__exposureData.get(id);
        if (!data) return;

        if (entry.isIntersecting && entry.intersectionRatio > 0) {
          if (!data._enteredAt) {
            data._enteredAt = now;
            if (!data.firstSeen) data.firstSeen = now;
          }
          if (entry.intersectionRatio > data.maxRatio) {
            data.maxRatio = entry.intersectionRatio;
          }
        } else {
          if (data._enteredAt) {
            data.totalVisibleMs += (now - data._enteredAt);
            data._enteredAt = null;
          }
        }
      });
    }, {
      threshold: [0, 0.25, 0.5, 0.75, 1.0]
    });

    elements.forEach(el => {
      const id = 'exp_' + (idCounter++);
      el.dataset.__exposureId = id;
      window.__exposureData.set(id, {
        tag: el.tagName.toLowerCase(),
        text: (el.textContent || '').trim().substring(0, 80),
        className: el.className ? String(el.className).substring(0, 60) : '',
        id: el.id || '',
        rect: el.getBoundingClientRect().toJSON(),
        firstSeen: null,
        totalVisibleMs: 0,
        maxRatio: 0,
        _enteredAt: null
      });
      observer.observe(el);
    });

    window.__exposureObserver = observer;
    return { tracked: elements.size };
  }`
})
```

### Step 3: Perform Automated Scroll Sequence

Use `browser_run_code` to scroll the page smoothly from top to bottom and back. This simulates a real user scanning the page.

```javascript
browser_run_code({
  code: `async (page) => {
    // Scroll to bottom smoothly over ~5 seconds
    const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight);
    const viewportHeight = await page.evaluate(() => window.innerHeight);
    const steps = 25;
    const stepSize = (scrollHeight - viewportHeight) / steps;

    for (let i = 1; i <= steps; i++) {
      await page.evaluate((y) => window.scrollTo({ top: y, behavior: 'smooth' }), stepSize * i);
      await page.waitForTimeout(200);
    }

    // Pause at bottom
    await page.waitForTimeout(2000);

    // Scroll back to top
    for (let i = steps - 1; i >= 0; i--) {
      await page.evaluate((y) => window.scrollTo({ top: y, behavior: 'smooth' }), stepSize * i);
      await page.waitForTimeout(200);
    }

    // Pause at top
    await page.waitForTimeout(1000);
    return 'Scroll complete';
  }`
})
```

### Step 4: Harvest Exposure Data

Use `browser_evaluate` to finalize timing for any currently-visible elements and extract the results.

```javascript
browser_evaluate({
  function: `() => {
    const now = performance.now();
    const results = [];

    window.__exposureData.forEach((data, id) => {
      // Close any open visibility session
      if (data._enteredAt) {
        data.totalVisibleMs += (now - data._enteredAt);
        data._enteredAt = null;
      }

      results.push({
        id: id,
        tag: data.tag,
        text: data.text,
        className: data.className,
        elementId: data.id,
        totalVisibleSec: Math.round(data.totalVisibleMs / 100) / 10,
        maxRatio: Math.round(data.maxRatio * 100),
        firstSeen: data.firstSeen ? Math.round(data.firstSeen) : null,
        neverSeen: data.firstSeen === null
      });
    });

    // Sort by totalVisibleSec descending
    results.sort((a, b) => b.totalVisibleSec - a.totalVisibleSec);

    const neverSeen = results.filter(r => r.neverSeen);
    const underOneSecond = results.filter(r => !r.neverSeen && r.totalVisibleSec < 1.0);
    const ctas = results.filter(r =>
      r.tag === 'button' || r.tag === 'a' ||
      r.className.includes('cta') || r.className.includes('btn')
    );
    const neverSeenCtas = ctas.filter(r => r.neverSeen);

    return {
      totalTracked: results.length,
      neverSeenCount: neverSeen.length,
      underOneSecondCount: underOneSecond.length,
      neverSeenCtas: neverSeenCtas,
      topExposed: results.slice(0, 15),
      neverSeen: neverSeen.slice(0, 20),
      underOneSecond: underOneSecond.slice(0, 20),
      allResults: results
    };
  }`
})
```

### Step 5 (Optional): Inject CSS Heatmap Overlay

Use `browser_evaluate` to color-code elements based on their exposure time. Green indicates high exposure, yellow is moderate, red means never or barely seen.

```javascript
browser_evaluate({
  function: `() => {
    window.__exposureData.forEach((data, id) => {
      const el = document.querySelector('[data-__exposure-id="' + id + '"]');
      if (!el) return;

      // Close any open session
      const now = performance.now();
      if (data._enteredAt) {
        data.totalVisibleMs += (now - data._enteredAt);
        data._enteredAt = null;
      }

      const sec = data.totalVisibleMs / 1000;
      let color;
      if (data.firstSeen === null) {
        color = 'rgba(255, 0, 0, 0.3)';       // Red: never seen
      } else if (sec < 1.0) {
        color = 'rgba(255, 165, 0, 0.3)';     // Orange: <1s
      } else if (sec < 3.0) {
        color = 'rgba(255, 255, 0, 0.25)';    // Yellow: 1-3s
      } else {
        color = 'rgba(0, 200, 0, 0.25)';      // Green: 3s+
      }

      el.style.outline = '2px solid ' + color.replace('0.3', '0.8').replace('0.25', '0.8');
      el.style.backgroundColor = color;
    });
    return 'Heatmap overlay applied';
  }`
})
```

### Step 6: Capture Full-Page Screenshot

Take a full-page screenshot showing the heatmap overlay.

```
browser_take_screenshot({ fullPage: true, type: "png", filename: "exposure-heatmap.png" })
```

## Interpreting Results

### Exposure Time Thresholds

| Exposure Time | Status | Meaning |
|---|---|---|
| 0 seconds (never seen) | Critical | Element is off-screen or hidden; users never see it |
| < 1 second | Warning | Barely visible; users likely scan past it |
| 1 - 3 seconds | Acceptable | Moderate exposure; may be noticed duri

Related in Writing & Docs