Do these 3 things before closing this tab:
1Fix the driver behind crashes, sound loss and screen glitches2Repair Windows errors before they cause bigger problems3Scan for outdated or missing drivers - takes under a minuteSome links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.
Use Chrome DevTools Protocol (CDP) when your Chromium driver supports captureBeyondViewport; otherwise scroll the page in overlapping segments, temporarily put sticky or fixed headers back into normal document flow, and stitch the images. Selenium’s ordinary screenshot methods capture the current viewport or an element, not an arbitrarily tall document. The two workflows below cover both cases, including lazy content, nested scroll areas, the final partial segment, and reliable CSS restoration.
Choose the capture method first
| Method | Best for | Sticky-header behavior | Main trade-off |
|---|---|---|---|
CDP Page.captureScreenshot |
Chromium automation where a single full-page image is sufficient | Usually captured once, but verify pages with unusual fixed layers | Chromium-specific and subject to browser/driver support |
| Scroll and stitch | Cross-browser workflows, custom handling, or pages with inner scroll containers | Repeats unless the header is temporarily neutralised | More code, image memory, and timing work |
Use the document’s own scroll surface when the content is the page. If the article, dashboard, or modal scrolls inside a panel, measure and scroll that element instead of calling window.scrollTo. A full-page routine cannot capture content that remains inside an unscrolled nested container.
Prerequisites and page stabilisation
- Install Selenium and a matching Chromium browser/driver. The examples use Python 3.
- Install Pillow for stitching:
pip install selenium pillow. - Use a deterministic viewport, such as 1440 by 900, and set the device scale factor deliberately if pixel dimensions matter.
- Wait for application content, fonts, and images before measuring height. Freeze animations and hide the caret when visual consistency matters.
The following wait helper waits for document readiness, web fonts, and currently known images. Applications that render after an API call should add an explicit wait for a selector that proves the data is present.
What’s actually slowing this PC down?
Pick the symptom - the matching free tool is one click away.
from selenium.webdriver.support.ui import WebDriverWait
def wait_for_page(driver, timeout=30):
WebDriverWait(driver, timeout).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
WebDriverWait(driver, timeout).until(lambda d: d.execute_async_script("""
const done = arguments[arguments.length - 1];
const images = Array.from(document.images);
const imageWait = Promise.all(images.map(img => {
if (img.complete) return Promise.resolve();
return new Promise(resolve => {
img.addEventListener('load', resolve, {once:true});
img.addEventListener('error', resolve, {once:true});
});
}));
Promise.all([document.fonts ? document.fonts.ready : Promise.resolve(), imageWait])
.then(() => done(true));
"""))
driver.execute_script("""
const s = document.createElement('style');
s.dataset.seleniumCapture = 'true';
s.textContent = `*, *::before, *::after {
animation: none !important; transition: none !important;
caret-color: transparent !important;
}`;
document.head.appendChild(s);
""")
Option 1: Chromium CDP full-page capture
Chrome’s DevTools Protocol provides a page screenshot operation with captureBeyondViewport. Ask the browser for layout metrics, use the content dimensions as the clip, and decode the returned base64 image. This avoids manually joining viewport strips.
import base64
from pathlib import Path
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
URL = "https://example.com/long-page"
OUT = Path("full-page.png")
options = Options()
options.add_argument("--window-size=1440,900")
driver = webdriver.Chrome(options=options)
try:
driver.get(URL)
wait_for_page(driver)
metrics = driver.execute_cdp_cmd("Page.getLayoutMetrics", {})
size = metrics.get("cssContentSize") or metrics["contentSize"]
width = float(size["width"])
height = float(size["height"])
result = driver.execute_cdp_cmd("Page.captureScreenshot", {
"format": "png",
"captureBeyondViewport": True,
"fromSurface": True,
"clip": {"x": 0, "y": 0, "width": width, "height": height, "scale": 1}
})
OUT.write_bytes(base64.b64decode(result["data"]))
finally:
driver.quit()
Some Selenium releases expose CDP commands through execute_cdp_cmd; other language bindings provide an equivalent DevTools interface. If the command is rejected, the browser is not Chromium, or the resulting image is clipped, use the stitching method. Very tall pages can also exceed browser or image-dimension limits; split the capture into sections when the PNG cannot be encoded safely.
#1 Best Overall
Do not automatically rewrite sticky headers for CDP. Because the browser captures the page outside the viewport in one operation, a fixed layer normally does not get stamped at every scroll offset. Test your target page: if a framework paints a repeated layer into the surface, apply the same temporary header neutralisation used by the stitching routine.
Option 2: Scroll, capture, and stitch while neutralising headers
A sticky or fixed header remains attached to the viewport. Each viewport screenshot therefore contains it, and a naïve composite repeats the header down the image. Save each header’s inline style, change its positioning to relative, clear its offsets, capture, and restore the exact original style in a finally block.
Free tools Windows power users keep installed
One-click scans. No signup required.
import io
import time
from pathlib import Path
from PIL import Image
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
URL = "https://example.com/long-page"
OUT = Path("stitched.png")
HEADER_SELECTORS = ["header", ".site-header", "[data-sticky-header]"]
OVERLAP = 32
NEUTRALISE = """
const selectors = arguments[0];
const found = [];
for (const selector of selectors) {
for (const el of document.querySelectorAll(selector)) {
if (found.includes(el)) continue;
found.push(el);
el.dataset.seleniumCaptureOriginalStyle = el.getAttribute('style') ?? '__NO_STYLE__';
el.style.setProperty('position', 'relative', 'important');
for (const prop of ['top','right','bottom','left']) {
el.style.setProperty(prop, 'auto', 'important');
}
}
}
return found.length;
"""
RESTORE = """
for (const el of document.querySelectorAll('[data-selenium-capture-original-style]')) {
const original = el.dataset.seleniumCaptureOriginalStyle;
if (original === '__NO_STYLE__') el.removeAttribute('style');
else el.setAttribute('style', original);
delete el.dataset.seleniumCaptureOriginalStyle;
}
"""
def wait_for_page(driver, timeout=30):
from selenium.webdriver.support.ui import WebDriverWait
WebDriverWait(driver, timeout).until(
lambda d: d.execute_script("return document.readyState") == "complete")
WebDriverWait(driver, timeout).until(lambda d: d.execute_async_script("""
const done = arguments[arguments.length - 1];
const pending = Array.from(document.images).map(img => img.complete ?
Promise.resolve() : new Promise(resolve => {
img.addEventListener('load', resolve, {once:true});
img.addEventListener('error', resolve, {once:true});
}));
Promise.all([document.fonts ? document.fonts.ready : Promise.resolve(), ...pending])
.then(() => done(true));
"""))
def capture_stitched(driver, selectors, output):
original_x, original_y = driver.execute_script(
"return [window.scrollX, window.scrollY];")
driver.execute_script(NEUTRALISE, selectors)
try:
total_width, total_height, viewport_height = driver.execute_script("""
return [document.documentElement.scrollWidth,
document.documentElement.scrollHeight,
window.innerHeight];
""")
if total_height <= 0 or viewport_height <= 0:
raise RuntimeError("The document has no measurable scroll surface")
starts = list(range(0, max(1, total_height - viewport_height + 1),
max(1, viewport_height - OVERLAP)))
last_start = max(0, total_height - viewport_height)
if starts[-1] != last_start:
starts.append(last_start)
pieces = []
for y in starts:
driver.execute_script("window.scrollTo(0, arguments[0]);", y)
time.sleep(0.15)
actual_y = driver.execute_script("return window.scrollY;")
png = driver.get_screenshot_as_png()
pieces.append((int(actual_y), Image.open(io.BytesIO(png)).convert("RGB")))
scale = pieces[0][1].width / max(1, driver.execute_script(
"return window.innerWidth;"))
canvas = Image.new("RGB", (round(total_width * scale),
round(total_height * scale)), "white")
for y, piece in pieces:
crop_height = min(piece.height, canvas.height - round(y * scale))
if crop_height > 0:
canvas.paste(piece.crop((0, 0, piece.width, crop_height)),
(0, round(y * scale)))
canvas.save(output)
finally:
driver.execute_script(RESTORE)
driver.execute_script("window.scrollTo(arguments[0], arguments[1]);",
original_x, original_y)
options = Options()
options.add_argument("--window-size=1440,900")
driver = webdriver.Chrome(options=options)
try:
driver.get(URL)
wait_for_page(driver)
capture_stitched(driver, HEADER_SELECTORS, OUT)
finally:
driver.quit()
Why the last segment needs special handling
Most viewport heights do not divide evenly into document height. The final start position is documentHeight - viewportHeight, not another full stride. The example appends that position when necessary and crops the pasted image to the remaining canvas height, preventing a blank tail or an out-of-bounds paste.
When a header selector is not enough
Some sites use several fixed bars, shadow DOM, or a wrapper that owns the sticky behavior. Add stable selectors for every bar. If the header is generated inside an open shadow root, query that root and save/restore its style separately. Do not remove the element: preserving its height keeps the document’s flow and offsets stable.
Nested scroll containers and lazy content
For a panel such as .results, measure element.scrollHeight and element.clientHeight, set element.scrollTop for each segment, and capture the panel element or viewport. Scrolling the window alone leaves lower panel content unloaded. Trigger each scroll position, wait for newly inserted images or rows, and re-measure if the application grows while you capture.
Rank #2
Lazy images can change page height after your first measurement. A robust routine either waits for all images after scrolling through the page once or performs a second height measurement before stitching. Keep a failure screenshot and browser log when a capture aborts; these artifacts show whether the problem was navigation, rendering, or image composition.
Timing, fidelity, and performance choices
- Fonts: wait for
document.fonts.ready; otherwise text reflow can shift later segments. - Animations: inject a temporary stylesheet disabling transitions and animations, then remove it after capture.
- Viewport and scale: choose them before measuring. A device scale factor changes the pixel dimensions even when CSS dimensions stay the same.
- Overlap: a small overlap helps hide one-pixel rounding differences, but excessive overlap increases memory and can duplicate content if cropping is wrong.
- Memory: a very tall RGB canvas is width × height × 3 bytes before encoder overhead. Capture or encode in sections when this approaches your worker’s memory limit.
- Reliability: pin browser and driver versions in CI, use explicit waits instead of arbitrary long sleeps, and save the URL, viewport, and options beside the image for reproducibility.
Troubleshooting common failures
| Symptom | Likely cause | Fix |
|---|---|---|
unknown command: Page.captureScreenshot |
Non-Chromium browser, old driver, or unavailable CDP binding | Use a compatible Chromium/driver pair or switch to scroll-and-stitch. |
| Header appears on every segment | position: sticky or fixed remains active |
Include the real header selector, save its style, set relative positioning and clear offsets, then restore it. |
| Only the visible viewport is saved | Used Selenium’s ordinary screenshot without a full-page strategy | Use CDP with captureBeyondViewport or the stitching routine. |
| Bottom content is missing | Wrong scroll surface, lazy loading, or an early height measurement | Scroll the owning container, trigger lazy content, wait, and re-measure. |
| Blank strip at the bottom | Every segment was pasted at a fixed viewport height | Use the final start position and crop the final piece to the remaining canvas. |
| Text shifts between pieces | Fonts or application rendering were still loading | Wait for fonts and a content-specific ready selector; disable animation. |
| Capture hangs or times out | Unresolved network requests, modal overlays, or a page that keeps extending | Set a bounded wait, dismiss overlays, stop after a stable height, and retain a diagnostic artifact. |
| Restoration leaves the page altered | An exception bypassed CSS restoration | Keep neutralisation and scroll restoration in finally; restore the original style attribute, not a guessed value. |
Or skip the browser setup
ScreenshotNeo returns a screenshot or PDF from one request, so you do not need to install Selenium, a browser, or a driver. Its capture pipeline accepts cookie and consent banners before the shot and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be disabled. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status.
cURL (see the ScreenshotNeo API documentation):
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
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)
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(`${res.status} ${res.statusText}`);
require('fs').writeFileSync('shot.webp', Buffer.from(await res.arrayBuffer()));
For full-page work, ScreenshotNeo also supports lazy-image loading, CSS-selector element capture, dark mode, 12 device presets plus custom viewports, retina scale, PDF paper sizes and page ranges, custom CSS and JavaScript, pre-capture clicks, selector or network-idle waits, ad/tracker/request blocking, headers, cookies, user agents, Authorization, timezone and geolocation, transparent backgrounds, resizing, caller-selected cache TTLs, signed image links, asynchronous jobs with signed webhooks, bulk capture of up to 100 URLs per call, a usage API, and an OpenAPI specification. An MCP server exposes take_screenshot, get_page_info, and capture_pdf to Claude, Cursor, and other MCP clients.
Rank #3
The Free plan includes 1,000 shots per month with no card. Paid plans are 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, and every feature is on every plan. Create a free ScreenshotNeo account to start.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Frequently Asked Questions
Can I use the CDP method with Firefox or Safari?
No. Page.captureScreenshot and captureBeyondViewport are Chromium DevTools features. Use the scroll-and-stitch strategy for a browser-independent implementation.
Recommended Free Tools
Rank #4
Should I permanently change my site’s header CSS for screenshots?
No. Save the original inline style, apply a temporary override only during a stitched capture, and restore it in a finally block.
Why is an element screenshot different from a full-page screenshot?
An element screenshot captures the selected element’s current rendered box. It does not automatically scroll the document or combine content that lies outside that box.
Quick Recap
Best Value
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

