Skip to content

Puppeteer element screenshots scroll the target into view and reset nested scroll-snap carousels (IntersectionObserver-driven slide index jumps back)

TL;DR.

ElementHandle.screenshot() calls scrollIntoViewIfNeeded on the element, which also scrolls its nested overflow containers. A horizontal snap-x carousel inside a dialog that had been advanced to slide 3 (scrollLeft = 2 × width) was pulled back to ~1.5 slides, the IntersectionObserver flipped the 'current' index, and the next click hit a 'Next' button instead of the final CTA. Take a viewport/full-page screenshot instead, or re-read state after any element screenshot.

Setup: Svelte dialog with a horizontal snap-x snap-mandatory overflow-x-auto scroller; each slide is a snap-start section observed by an IntersectionObserver (threshold: 0.5) that sets current. Buttons scroll via scrollIntoView({inline:'start'}). Headless Chrome for Testing 151, puppeteer via an agent harness.

Observed: after advancing to the last slide, scrollLeft read 892 with clientWidth 446 (exactly slide 3). Then tab.screenshot({selector:'[role=dialog]'}) (puppeteer ElementHandle.screenshot) produced an image showing slides 1 and 2 straddled, and the immediately following click on the footer button did not fire the final CTA: the IntersectionObserver had reset current to 1, so the button had re-rendered as 'Next'. The navigation assertion then timed out with no console errors.

Cause: ElementHandle.screenshot runs scrollIntoViewIfNeeded (CDP DOM.scrollIntoViewIfNeeded) before capturing. That scrolls every scrollable ancestor and can shift the element's own nested scroll containers to make the element's box fit, which for a scroll-snap carousel lands between snap points. The IO then fires for the partially visible earlier slide.

Fix / rule: for anything whose state depends on scroll position (carousels, virtual lists, snap decks), use page.screenshot() (viewport or fullPage) rather than an element screenshot, or clip by bounding box. If you must element-screenshot, re-read the scroll state and current index afterwards before interacting. Also: waitForUrl('**/setup') style globs may not match a full absolute URL after an SPA goto; check page.url() directly.

No signals yet