How to Screenshot a Long Webpage with Nested Scrolling Containers
A full-page screenshot captures the document, not every scrollable panel inside it. Learn how to find nested scrollers and capture their hidden content with Playwright.
A full-page screenshot captures the document’s scrollable page; it does not automatically reveal content clipped inside a nested scrolling container such as a panel, modal, or div. To capture that hidden content, find the element that owns the scrollbar, scroll it through its range, and save overlapping screenshots of the exposed sections. If you need the document and the panel, capture them separately.
This guide uses Playwright because it can automate both page and element screenshots. Firefox Developer Tools also offers full-page and node screenshots for manual capture, but a node screenshot should not be assumed to expand hidden overflow. See the Playwright screenshot guide, its nested scrolling discussion, and Firefox screenshot documentation.
1. Understand what “full page” captures
A page can have its own scroll position and one or more independently scrolling descendants. For example, a dashboard may keep the document fixed while a results panel scrolls. A page-level full-page screenshot covers the document’s full scrollable page; it does not expand each child’s overflow: auto or overflow: scroll region. Playwright’s maintainer explained that full-page screenshots do not expand scrollable containers, and a screenshot of a scrollable element shows its currently scrolled content.
| Capture | What it targets | What to expect with a nested scroller |
|---|---|---|
| Page full-page screenshot | The document’s scrollable page | Does not reveal all clipped child content |
| Element screenshot | A specific element | For a scrollable element, captures its currently visible scrolled portion |
| Repeated element screenshots | Successive positions of the scroller | Can cover hidden content; combine the images separately if one tall image is required |
2. Find which element owns the scrollbar
- Load the page and note whether the document itself has a scrollbar.
- Scroll over the suspected panel. If its contents move while the page position stays put, that panel is an independent scroller.
- Inspect the panel in browser developer tools and identify a stable selector, such as
.results-panelor#message-list. - Check whether the panel contains another independently scrolling child. Treat every independently moving region as its own capture target.
Do not choose a selector solely because it wraps the content. The element to capture and scroll must be the one whose scrollTop changes. A useful browser-console check is:
const el = document.querySelector('.results-panel');
({
clientHeight: el?.clientHeight,
scrollHeight: el?.scrollHeight,
scrollTop: el?.scrollTop,
overflows: el ? el.scrollHeight > el.clientHeight : false
});
A scrollHeight greater than clientHeight indicates content extends beyond the element’s visible box, but does not by itself prove that this element is the one currently scrolling. Try changing scrollTop and check which content moves.
3. Capture the document and panel with Playwright
Install Playwright and its Chromium browser in a Node.js project:
npm install playwright
npx playwright install chromium
Save this as capture.mjs, replace the URL and selector, then run node capture.mjs. It saves the full document and successive viewport-sized captures of the selected scroll container. The final panel capture is positioned at the bottom, so the trailing content is included even when the scroll distance is not an exact multiple of the step.
import { chromium } from 'playwright';
const url = 'https://example.com';
const selector = '.results-panel';
const overlap = 80;
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.locator(selector).waitFor({ state: 'visible', timeout: 15000 });
// This captures the document, not all nested overflow regions.
await page.screenshot({ path: 'document.png', fullPage: true });
const panel = page.locator(selector);
const metrics = await panel.evaluate(el => ({
height: el.clientHeight,
scrollHeight: el.scrollHeight,
scrollTop: el.scrollTop
}));
if (metrics.height <= 0) throw new Error('The selected container has no visible height.');
if (metrics.scrollHeight <= metrics.height) {
await panel.screenshot({ path: 'panel.png' });
console.log('The selected element has no additional vertical overflow.');
} else {
const step = Math.max(1, metrics.height - overlap);
const maxTop = metrics.scrollHeight - metrics.height;
let index = 0;
for (let top = 0; ; top = Math.min(top + step, maxTop)) {
await panel.evaluate((el, y) => { el.scrollTop = y; }, top);
// Allow layout and visible lazy content a moment to update.
await page.waitForTimeout(150);
await panel.screenshot({ path: `panel-${String(index).padStart(3, '0')}.png` });
index++;
if (top === maxTop) break;
}
console.log(`Saved ${index} panel captures.`);
}
} finally {
await browser.close();
}
The files panel-000.png, panel-001.png, and so on are separate sections. Their overlap helps you align or stitch them in an image editor. Playwright’s element screenshot does not itself stitch the positions into one image.
4. Handle nested panels and page movement
If there are multiple scrolling areas, capture each target separately. Before capturing an inner child, put its parent at the position where that child is visible. If scrolling the child also moves the document or a parent panel, record and restore those other scroll positions as needed so the screenshots have consistent context.
For repeatable automation, use a stable selector and set the intended ancestor positions before each capture. This helper captures the current visible content of a selector at successive vertical positions; call it only when its containing ancestors are already positioned correctly:
async function captureScroller(page, selector, prefix, overlap = 80) {
const target = page.locator(selector);
await target.waitFor({ state: 'visible' });
const { height, scrollHeight } = await target.evaluate(el => ({
height: el.clientHeight,
scrollHeight: el.scrollHeight
}));
if (!height) throw new Error(`No visible height for ${selector}`);
const maxTop = Math.max(0, scrollHeight - height);
const step = Math.max(1, height - overlap);
let i = 0;
for (let top = 0; ; top = Math.min(top + step, maxTop)) {
await target.evaluate((el, y) => { el.scrollTop = y; }, top);
await page.waitForTimeout(150);
await target.screenshot({ path: `${prefix}-${String(i++).padStart(3, '0')}.png` });
if (top === maxTop) break;
}
return i;
}
For horizontal overflow, use the same approach with scrollLeft, scrollWidth, and clientWidth. If both axes overflow, decide whether you need a grid of captures or only vertical coverage, and keep overlap on both axes for alignment.
5. Optional: stitch the sections into one image
Stitching is a separate image-processing step, not a Playwright screenshot option. Because each capture overlaps the previous one, crop the repeated strip before placing the next image. The simple Pillow example below removes a fixed overlap from every image after the first; it assumes the page content did not shift and that the overlap is exactly the configured pixel height.
from PIL import Image
files = [f'panel-{i:03d}.png' for i in range(10)] # Set the actual file count.
overlap = 80
images = [Image.open(name).convert('RGB') for name in files]
if not images:
raise SystemExit('No panel images supplied')
width = images[0].width
if any(image.width != width for image in images):
raise ValueError('All captures must have the same width')
parts = [images[0]]
for image in images[1:]:
if image.height <= overlap:
raise ValueError('Overlap must be smaller than each later capture')
parts.append(image.crop((0, overlap, width, image.height)))
result = Image.new('RGB', (width, sum(part.height for part in parts)))
y = 0
for part in parts:
result.paste(part, (0, y))
y += part.height
result.save('panel-stitched.png')
Install Pillow with python -m pip install Pillow. Update the filename list to match the captures. Fixed cropping can produce seams when content moves, sticky elements change, or the final capture overlaps by a different amount. For those cases, align sections visually or keep them as separate images rather than implying a seamless composite.
6. Firefox Developer Tools for a manual capture
Firefox Developer Tools documents a toolbox button for taking a screenshot of the entire page; enable it in DevTools settings if it is not visible. The Inspector context menu also offers “Screenshot Node” for a selected element. Use the full-page control for the document and the node action for the currently visible target. The cited documentation does not say node screenshots expand a node’s hidden overflow, so scroll the target and capture successive positions when you need its full contents.
7. Or skip the browser setup
ScreenshotNeo is a website screenshot API and MCP server. A single request captures a URL as an image or PDF. This is useful when you want a hosted page capture without installing and managing a browser. Its full-page option loads lazy images, but a page screenshot should not be assumed to expand nested scroll containers; use the Playwright workflow above when you specifically need to expose and capture the hidden positions inside a panel.
See the ScreenshotNeo API documentation. This cURL example saves a screenshot of the target page:
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
Equivalent Python:
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Equivalent Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', new Uint8Array(await res.arrayBuffer()));
Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed. An MCP server lets AI agents use screenshot tools. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Every feature is available on every plan.
Sign up for 1,000 free screenshots a month, with no card required.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Full-page image is short, but the panel has more content | The page and panel have separate scroll positions | Capture the panel at successive scrollTop positions |
| Element screenshot contains only a slice | Element screenshots show the scroller’s current visible position | Scroll and save multiple captures; stitching is separate |
| No panel screenshots or timeout waiting for selector | Selector is wrong, element is hidden, or content has not loaded | Inspect the live DOM, use a stable selector, and wait for the actual panel to become visible |
| Only one image is saved although more content exists | The selected wrapper does not own the scrollbar, or its dimensions were measured before content loaded | Check which element’s scrollTop changes; wait for content and measure again |
| Captures repeat the same section | The assigned scroll position is being overridden, or the target is not the actual scroller | Read back scrollTop after setting it and inspect overflow on ancestors and descendants |
| Gaps or duplicate rows in a stitched image | Overlap crop does not match, or content shifted between captures | Use consistent overlap, wait for dynamic content, and align manually or retain separate sections |
| Images have different heights or widths | The viewport or target dimensions changed during capture | Keep viewport and layout stable; avoid resizing between positions |
| Sticky header appears repeatedly | The header is sticky within each captured viewport | Keep the separate sections, or hide the sticky element with page CSS before capture if appropriate |
| Bottom content is missing | The last scroll position did not reach the maximum, or content loads only near the bottom | Capture exactly at scrollHeight - clientHeight, wait for new content, then remeasure |
9. Performance, reliability, and cost
- Capture count: for panel height
H, viewport heightV, and overlapO, the approximate number of images is1 + ceil(max(0, H - V) / (V - O)). Larger overlap eases alignment but adds captures. - Image size: high device scale factors and wide panels increase file size and memory use. Capture at the dimensions you need and stitch only when a single image is useful.
- Dynamic pages: animation, live updates, and content inserted while scrolling can create missing or duplicated material. Pause updates where possible, wait for the target to render, and check the first, middle, and last captures.
- Lazy loading: some pages load items only when a region approaches the viewport. Scroll in steps, wait for rendering, and re-read dimensions because
scrollHeightmay grow during capture. - Repeatability: use a stable viewport, selector, browser version, and page state. For authenticated content, establish the intended session before navigating and ensure the account is allowed to access the page.
- Cost: Playwright is open source, but automated capture still uses compute and storage you operate. ScreenshotNeo charges by plan for clean shots; its stated plans are Free with 1,000 per month, Starter $5 for 3,000, Growth $15 for 15,000, Pro $39 for 60,000, Scale $99 for 250,000, and Business $249 for 1,000,000. Yearly billing gives two months free. Only clean shots are billed; cache hits and failed or blocked captures are not billed.
10. Frequently asked questions
Can I make a browser’s full-page screenshot expand every scrollable panel?
Do not assume so. Full-page capture concerns the document. Expose nested content by scrolling the panel and capturing its positions.
Will one screenshot of the panel include all its hidden content?
Playwright’s locator screenshot captures the element at its current scroll position. Save successive positions for the rest.
Should I stitch every capture into one very tall image?
Only if the result is useful at its intended viewing size. Separate sections are often easier to inspect, and stitching can misalign when the page changes.
What if several panels scroll independently?
Capture each scroller as its own target, placing its ancestors at the desired positions before capture. Record those positions when automation must reproduce the same view.


