How to Capture a Full-Page Screenshot With Sticky Elements in Safari Using Java
Capture Safari pages beyond the viewport in Java with Selenium, scroll-and-stitch logic, and a reliable policy for sticky elements.
Short answer: SafariDriver screenshots cover the current viewport. To create a full-page image, use Selenium with Java to capture overlapping viewport images while scrolling, then stitch them together. Keep sticky and fixed elements in the first frame and hide or mask them in later frames so they do not repeat.
Apple documents Safari WebDriver through safaridriver and Selenium clients, including Java (Apple WebDriver guide, Selenium Safari documentation). The W3C screenshot command captures the top-level browsing context’s initial viewport, so TakesScreenshot#getScreenshotAs alone is not a document-height capture (W3C screenshot command, Selenium TakesScreenshot API).
How the workflow works
- Enable Safari’s driver and create a Java
SafariDriver. - Wait for the page and lazy content to settle.
- Measure the viewport and document dimensions.
- Capture viewport-sized PNGs with a small overlap.
- Scroll, wait for layout changes, and repeat until the bottom is covered.
- Hide or mask sticky and fixed elements after the first capture.
- Stitch only non-overlapping portions and inspect seams.
WebdriverIO documents a hide-after-first-scroll option for full-page screenshots. It is useful evidence for this mitigation pattern, but it is not a universal Safari guarantee (WebdriverIO screenshot options).
Prerequisites and Safari setup
Enable safaridriver
safaridriver --enable
Run this on the macOS account that executes the test. Record the Safari, macOS, and Selenium versions used. SafariDriver is supplied with the operating system according to Selenium’s Safari documentation.
Add Selenium for Java
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>YOUR_SELENIUM_VERSION</version>
</dependency>
Use the Selenium version supported by your project. A first run may require macOS approval for browser automation.
Complete Java scroll-and-stitch example
This reference captures viewport images, uses an overlap, recalculates document height, and writes a final JPEG. Adapt selectors, waits, image scaling, and nested-scroll handling for the target site.
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import javax.imageio.ImageIO;
import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.safari.SafariDriver;
import org.openqa.selenium.safari.SafariOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
public class SafariFullPageShot {
private static final String URL = "https://example.com/article";
private static final int OVERLAP_CSS_PX = 80;
public static void main(String[] args) throws Exception {
SafariOptions options = new SafariOptions();
WebDriver driver = new SafariDriver(options);
try {
driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(60));
driver.get(URL);
new WebDriverWait(driver, Duration.ofSeconds(30))
.until(ExpectedConditions.presenceOfElementLocated(By.cssSelector("body")));
JavascriptExecutor js = (JavascriptExecutor) driver;
waitForLayout(js);
long viewportHeight = number(js, "return window.innerHeight;");
long dpr = number(js, "return window.devicePixelRatio || 1;");
int step = (int)Math.max(1, viewportHeight - OVERLAP_CSS_PX);
List<Path> frames = new ArrayList<>();
long y = 0;
int index = 0;
while (true) {
js.executeScript("window.scrollTo(0, arguments[0]);", y);
waitForLayout(js);
Path frame = Files.createTempFile("safari-frame-" + index + "-", ".png");
Files.write(frame, ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES));
frames.add(frame);
long height = documentHeight(js);
long maxY = Math.max(0, height - viewportHeight);
if (y >= maxY) break;
long next = Math.min(maxY, y + step);
if (next <= y) break;
y = next;
index++;
}
BufferedImage stitched = stitch(frames, (int)dpr, OVERLAP_CSS_PX);
ImageIO.write(stitched, "jpg", new File("safari-full-page.jpg"));
for (Path frame : frames) Files.deleteIfExists(frame);
} finally {
driver.quit();
}
}
static long number(JavascriptExecutor js, String script) {
return ((Number)js.executeScript(script)).longValue();
}
static long documentHeight(JavascriptExecutor js) {
return number(js, "return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);");
}
static void waitForLayout(JavascriptExecutor js) throws InterruptedException {
Thread.sleep(250);
js.executeScript("return document.fonts ? document.fonts.ready : null;");
}
static BufferedImage stitch(List<Path> files, int dpr, int overlapCss) throws IOException {
List<BufferedImage> images = new ArrayList<>();
int width = 0, totalHeight = 0;
for (Path file : files) {
BufferedImage image = ImageIO.read(file.toFile());
images.add(image); width = Math.max(width, image.getWidth()); totalHeight += image.getHeight();
}
int overlap = overlapCss * dpr;
totalHeight -= Math.max(0, images.size() - 1) * overlap;
BufferedImage output = new BufferedImage(width, totalHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = output.createGraphics();
int destinationY = 0;
for (int i = 0; i < images.size(); i++) {
BufferedImage image = images.get(i);
int sourceY = i == 0 ? 0 : overlap;
int height = image.getHeight() - sourceY;
graphics.drawImage(image, 0, destinationY, width, destinationY + height, 0, sourceY, image.getWidth(), image.getHeight(), null);
destinationY += height;
}
graphics.dispose();
return output;
}
}
Sticky suppression must happen between captures. Capture frame zero with the original page, inject a reversible style that hides selected sticky selectors, then capture subsequent positions. Changing the DOM after all captures cannot remove pixels already saved.
Handling sticky and fixed elements
| Policy | When to use it | Implementation |
|---|---|---|
| First frame only | Navigation should appear once | Capture frame zero, hide the selector, then continue |
| Mask | The overlay matters but must not repeat | Paint over its region in later images |
| Keep every frame | The repeated bar is part of the record | Leave the DOM unchanged and crop overlap carefully |
| Remove entirely | Chat, ads, or controls obscure content | Hide known selectors before capture |
Prefer page-specific selectors. A class named sticky may not identify every fixed element. Controls inside iframes, shadow roots, or nested scrolling containers need separate handling.
Waiting for lazy content and changing height
- Wait for an article selector, not only
document.readyState. - After every scroll, allow images, fonts, and intersection observers to settle.
- Re-read
scrollHeighton every iteration. - Freeze animations where possible and remove injected CSS afterward.
- For infinite scroll, define a sentinel or maximum number of rounds.
Edge cases
- Retina: PNG dimensions can exceed CSS dimensions. Convert overlap from CSS pixels to image pixels.
- Nested scrollers: scroll the container or expand it; scrolling the window will not reveal its contents.
- Iframes: switch into same-origin frames. Cross-origin frames cannot be inspected by page JavaScript.
- Shadow DOM: query each open shadow root. Closed roots require an application hook.
- Very tall pages: write tiles or use a format that tolerates large dimensions.
- Consent dialogs: handle them before measuring height.
- Video and animation: pause them or expect inconsistent seams.
- Responsive breakpoints: keep the window size fixed throughout the run.
Playwright Java alternative
Playwright Java documents a fullPage screenshot option (Playwright Java screenshots). That documentation does not establish that it drives Safari’s installed safaridriver. Confirm whether native Safari fidelity or a WebKit-based browser is required before switching.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| SafariDriver will not start | Driver disabled or permission missing | Run safaridriver --enable, approve automation, and verify versions. |
| Only the top viewport is saved | A viewport screenshot was treated as full page | Scroll, capture every viewport, and stitch. |
| Header repeats | Sticky element remained visible | Capture the first frame, hide or mask the selector, then continue. |
| Blank or duplicated bands | CSS-pixel overlap applied to high-DPI pixels | Multiply overlap by the device-pixel ratio. |
| Bottom content missing | Lazy loading changed height | Wait after scrolling and recalculate height each loop. |
| Different layouts at seams | Animation, fonts, ads, or resizing | Freeze animation, wait for fonts, and keep dimensions constant. |
| Capture never finishes | Infinite scroll | Use a sentinel or explicit iteration limit. |
Performance, reliability, and cost
- Runtime grows with the number of viewport steps. Larger steps reduce captures but increase seam risk.
- Memory usage follows final pixel dimensions. Stream tiles for long pages.
- Pin Safari, macOS, Selenium, viewport, timezone, locale, and page data for repeatability.
- Record URL, versions, viewport size, device-pixel ratio, scroll positions, and final height.
- Retries should restart from a known scroll position because the page may change.
- The Selenium workflow uses local browser and machine resources.
Or skip the browser setup
ScreenshotNeo provides full-page website screenshots, lazy-image loading, custom CSS and JavaScript, and an MCP server for AI agents. 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 \
-d full_page=true \
-o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key":"YOUR_API_KEY", "url":"https://stripe.com", "full_page":"true"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com', full_page: 'true' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
require('fs').writeFileSync('shot.webp', Buffer.from(await res.arrayBuffer()));
Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are never billed; responses identify the result with X-Page-Verdict and X-Billed. The MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. Free includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots, and every feature is on every plan. Sign up free.
FAQ
Does SafariDriver have a native full-page command?
The documented WebDriver screenshot command is scoped to the initial viewport. Use scroll-and-stitch or a service that performs it.
Should a sticky header appear in the final image?
Choose whether it appears once, is masked, repeats, or is removed based on the purpose of the screenshot.
Why is the result too tall?
The overlap was probably not removed, or CSS-pixel overlap was applied directly to a high-DPI image.
Can Playwright replace SafariDriver?
Only when a WebKit or other browser satisfies your fidelity requirement.


