ScreenshotNeo

BlogEngineering

Why HTML2Canvas ForeignObjectRendering Captures Only the Viewport

ForeignObjectRendering follows the browser viewport by default. Learn how to set full-page dimensions, avoid canvas limits, and fix cut-off captures.

By the ScreenshotNeo team30 September 20268 min read

Why HTML2Canvas ForeignObjectRendering Captures Only the Viewport

Short answer: foreignObjectRendering does not mean “capture the whole page.” html2canvas defaults windowWidth and windowHeight to window.innerWidth and window.innerHeight. Those values define the cloned document’s rendering window, so the ForeignObjectRenderer usually receives viewport-sized bounds. Content below or beside that boundary is never painted into the canvas.

Use the target element’s scroll dimensions for the cloning window and, when you need an explicit output boundary, for width and height too:

const element = document.documentElement;

const canvas = await html2canvas(element, {
  foreignObjectRendering: true,
  windowWidth: element.scrollWidth,
  windowHeight: element.scrollHeight,
  width: element.scrollWidth,
  height: element.scrollHeight
});

document.body.appendChild(canvas);

The configuration reference documents these options, and the project FAQ specifically recommends using scrollWidth and scrollHeight when output is cut off: html2canvas configuration and official FAQ.

What ForeignObjectRendering is actually doing

html2canvas is a DOM and CSS reconstruction library. It reads the page, clones relevant nodes, serializes the clone into an SVG foreignObject, loads that SVG as an image, and draws the image into a canvas. It does not ask the browser for a native screenshot surface.

At the entry point, html2canvas obtains default window dimensions from the document’s default view. Unless you override them, those defaults are the current viewport. The cloned document is laid out using a windowBounds object, and the renderer then chooses either the regular renderer or ForeignObjectRenderer.

The ForeignObjectRenderer creates a canvas using options.width * options.scale and options.height * options.scale. It gives the serialized foreignObject the same scaled dimensions, loads it, then draws it after applying the configured x and y offsets. If width and height remain viewport-sized, the output boundary remains viewport-sized as well. The source marks this renderer as experimental; behavior can vary with browsers, CSS, fonts, and resources. See the official source repository.

Capture the full document

1. Measure the actual target

Do not assume that document.documentElement is the right target. A dashboard may have a scrollable app shell, while a report may be a single article element. Inspect the dimensions before choosing options:

The cloning window and canvas boundary must both cover the document’s scroll dimensions.
The cloning window and canvas boundary must both cover the document’s scroll dimensions.
const element = document.querySelector('#report') ?? document.documentElement;
const rect = element.getBoundingClientRect();

console.table({
  rectWidth: rect.width,
  rectHeight: rect.height,
  scrollWidth: element.scrollWidth,
  scrollHeight: element.scrollHeight,
  clientWidth: element.clientWidth,
  clientHeight: element.clientHeight
});

getBoundingClientRect() describes the visible border box at its current position. scrollWidth and scrollHeight include overflow content. For a full-page capture, the scroll dimensions are usually the useful values.

2. Pass both cloning and output dimensions

async function captureFullPage(selector = 'html') {
  const element = document.querySelector(selector);
  if (!element) throw new Error(`No element matched ${selector}`);

  const width = element.scrollWidth;
  const height = element.scrollHeight;

  if (!width || !height) {
    throw new Error(`Target has invalid dimensions: ${width}x${height}`);
  }

  return html2canvas(element, {
    foreignObjectRendering: true,
    windowWidth: width,
    windowHeight: height,
    width,
    height,
    x: 0,
    y: 0,
    scrollX: window.scrollX,
    scrollY: window.scrollY,
    backgroundColor: '#ffffff'
  });
}

const canvas = await captureFullPage();
const link = document.createElement('a');
link.download = 'full-page.png';
link.href = canvas.toDataURL('image/png');
link.click();

windowWidth and windowHeight control the cloned browser window. width and height control the canvas boundary. Setting all four removes ambiguity and makes a cut-off result easier to diagnose.

3. Capture a scrollable application panel

If the page itself does not scroll but a panel does, use that panel’s scroll dimensions. You may also need to temporarily remove clipping, because overflow: hidden or a fixed height can prevent descendants from being painted outside the panel.

const panel = document.querySelector('.results');
const oldHeight = panel.style.height;
const oldOverflow = panel.style.overflow;

try {
  panel.style.height = `${panel.scrollHeight}px`;
  panel.style.overflow = 'visible';

  const canvas = await html2canvas(panel, {
    foreignObjectRendering: true,
    windowWidth: panel.scrollWidth,
    windowHeight: panel.scrollHeight,
    width: panel.scrollWidth,
    height: panel.scrollHeight
  });

  document.querySelector('#preview').replaceChildren(canvas);
} finally {
  panel.style.height = oldHeight;
  panel.style.overflow = oldOverflow;
}

Make this change only while capturing. Restoring the styles prevents layout changes for users.

Options that affect the boundary

Option Purpose Typical full-page value
windowWidth Width used while cloning and laying out the document element.scrollWidth
windowHeight Height used while cloning and laying out the document element.scrollHeight
width Canvas width before scale is applied element.scrollWidth
height Canvas height before scale is applied element.scrollHeight
scale Output pixel density; defaults to the device pixel ratio Lower it for very large pages
x, y Origin of the rendered region 0, 0 for a document capture
scrollX, scrollY Scroll positions used during rendering Set deliberately for fixed elements or a scrolled target
foreignObjectRendering Uses the SVG foreignObject path true

Changing only foreignObjectRendering selects a renderer; it does not request a larger viewport. Likewise, changing windowWidth without width and height can leave the final canvas smaller than the cloned layout.

Fixed, sticky, and positioned content

Fixed elements are positioned relative to the viewport, not the document. A cookie banner, header, or floating chat button can therefore appear at the top or bottom of every capture. Decide whether it belongs in the image and use onclone to alter the cloned document without changing the live page:

const canvas = await html2canvas(document.documentElement, {
  foreignObjectRendering: true,
  windowWidth: document.documentElement.scrollWidth,
  windowHeight: document.documentElement.scrollHeight,
  width: document.documentElement.scrollWidth,
  height: document.documentElement.scrollHeight,
  onclone: clonedDocument => {
    clonedDocument.querySelectorAll('.cookie-banner, .chat-widget')
      .forEach(node => node.remove());
  }
});

For a scrolled target, set scrollX and scrollY explicitly. If a sticky header overlaps content, either keep the intended scroll position or hide the header in the clone.

Canvas limits and oversized pages

A full document can exceed browser canvas limits. The official FAQ says limits vary by browser and platform; oversized canvases may be blank or partially rendered without throwing an exception. Rough limits in evergreen browsers are around 32,767 pixels per dimension, with separate area limits that also vary.

Before rendering, calculate the scaled size:

const scale = window.devicePixelRatio;
const pixels = element.scrollWidth * element.scrollHeight * scale * scale;
console.log({ width: element.scrollWidth * scale,
              height: element.scrollHeight * scale,
              pixels });

If the page is too large:

  • Lower scale to 1 or less.
  • Capture logical sections and stitch them together.
  • Capture only the required element instead of the entire document.
  • Prefer a browser-native full-page screenshot when exact layout and very tall pages matter.

CSS, fonts, and cross-origin resources

Because html2canvas reconstructs the page, it cannot guarantee native screenshot fidelity for every CSS feature. Unsupported CSS, cross-origin images without suitable CORS headers, blocked fonts, animations, and delayed lazy loading can all change the result. Wait for fonts and images before capture:

await document.fonts.ready;
await Promise.all(
  [...document.images].map(img => img.complete
    ? Promise.resolve()
    : new Promise(resolve => {
        img.addEventListener('load', resolve, { once: true });
        img.addEventListener('error', resolve, { once: true });
      }))
);

Freeze animations in the clone when deterministic output matters:

const canvas = await html2canvas(element, {
  foreignObjectRendering: true,
  windowWidth: element.scrollWidth,
  windowHeight: element.scrollHeight,
  width: element.scrollWidth,
  height: element.scrollHeight,
  onclone: clonedDocument => {
    const style = clonedDocument.createElement('style');
    style.textContent = '* { animation: none !important; transition: none !important; }';
    clonedDocument.head.appendChild(style);
  }
});

Common errors and fixes

Symptom Likely cause Fix
Only the visible viewport appears Default innerWidth/innerHeight are still in use Pass scroll dimensions as window, width, and height options
Bottom is cut off Target has a scroll container or fixed height Capture the scrolling element and temporarily expand its height
Right side is missing Horizontal overflow is outside the measured target Use the element’s scrollWidth and inspect overflow ancestors
Canvas is blank Browser canvas size or area limit Reduce scale, split the capture, or use native automation
Fixed header repeats or covers content Viewport-positioned element is cloned Set scroll positions or remove it in onclone
Images are missing Cross-origin restrictions or images not loaded Configure CORS, wait for images, and verify the image URL
Fonts or layout differ Fonts have not loaded or CSS is unsupported Await document.fonts.ready and reduce the case to a reproducible page
Firefox differs from Chrome ForeignObjectRendering is experimental and browser-specific Compare the default renderer and test a minimal example

Performance, reliability, and cost

Rendering time grows with DOM size, CSS complexity, image count, and output pixels. A high device-pixel ratio multiplies memory use because both dimensions are scaled. Measure dimensions first, avoid capturing hidden application shells, and disable animations and unnecessary widgets in the clone.

For repeatable jobs, wait for fonts and images, use a fixed viewport, and record the browser and html2canvas version. Keep a fallback path because ForeignObjectRendering is experimental and unsupported CSS can produce browser-specific output. Do not treat a resolved promise as proof that every pixel rendered; inspect the canvas dimensions and image content.

html2canvas runs in the user’s browser, so its direct software cost is typically your page’s client-side CPU and memory. A server-side screenshot service adds an API request and service pricing but avoids shipping browser automation and dealing with per-browser canvas limits.

Or skip the browser setup

ScreenshotNeo provides a website screenshot API and MCP server. One GET request returns PNG, JPEG, WebP, or PDF, so you do not need to maintain a browser page, clone DOM nodes, or tune canvas dimensions.

cURL:

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(`HTTP ${res.status}`);
const fs = await import('node:fs/promises');
await fs.writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

See the ScreenshotNeo API documentation for parameters. Cookie banners, newsletter popups, and chat widgets are removed before the shot; each of those steps can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. The MCP server includes take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 shots.

Create a free ScreenshotNeo account and start with the 1,000 monthly screenshots.

FAQ

Does foreignObjectRendering: true guarantee a full-page image?

No. It selects the foreign-object renderer. Full-page output still depends on the effective window, width, height, target, and browser canvas limits.

A capture pipeline can remove overlays before rendering the final image.
A capture pipeline can remove overlays before rendering the final image.

Should I use body.scrollHeight or documentElement.scrollHeight?

Measure both when diagnosing a page. Use the element that actually owns the scrollable content; on many documents that is document.documentElement.

Why do larger windowWidth values sometimes appear ignored?

Historical html2canvas issues show version-specific ForeignObjectRendering behavior. Reproduce the case with the current release, pass explicit output dimensions, and compare the default renderer.

Can html2canvas capture a page from another origin?

The library runs in the current page and is subject to browser security and resource policies. Cross-origin images and fonts need compatible CORS handling.

When should I use native browser automation?

Use it when you need browser-consistent full-page capture, very tall pages, or CSS behavior that DOM reconstruction cannot reproduce reliably.