ScreenshotNeo

BlogHow-to

How to Fix Vertical Cropping After html2canvas onclone Changes

Fix html2canvas captures that lose the bottom after onclone changes by matching window dimensions, removing clone constraints, and correcting scroll origins.

By the ScreenshotNeo team1 October 20269 min read

When html2canvas loses the bottom of a page after an onclone change, the cloned document is usually still being rendered inside a short window or a constrained scroll container. The repair is to measure the target, set windowWidth and windowHeight to the content dimensions, remove clone-only height and overflow limits, and set the intended scroll origin.

The onclone callback edits a temporary document. html2canvas constructs its rendering bounds from windowWidth, windowHeight, scrollX, and scrollY, then loads that clone for rendering. A change in the clone can therefore expose a different layout from the live page. See the official configuration reference and FAQ.

1. The reliable repair

Start with this pattern for a tall element. It measures the live target, gives html2canvas a window large enough for the content, and removes capture-only constraints in the clone.

import html2canvas from 'html2canvas';

const target = document.querySelector('#capture');
if (!target) throw new Error('Missing #capture');

const canvas = await html2canvas(target, {
  windowWidth: target.scrollWidth,
  windowHeight: target.scrollHeight,
  scrollX: 0,
  scrollY: 0,
  onclone: (doc) => {
    const cloneTarget = doc.querySelector('#capture');
    if (!cloneTarget) return;

    cloneTarget.style.height = `${cloneTarget.scrollHeight}px`;
    cloneTarget.style.maxHeight = 'none';
    cloneTarget.style.overflow = 'visible';
  }
});

document.querySelector('#output').replaceChildren(canvas);

The dimensions follow the html2canvas FAQ recommendation to match the element’s scroll dimensions. The style changes are made only in the clone, so the live page keeps its normal layout.

2. Diagnose the crop before changing code

Log the target’s scroll and viewport dimensions before capture. This tells you whether the source really contains more content than the visible box and whether the crop occurs at a layout boundary.

function inspectTarget(target) {
  const rect = target.getBoundingClientRect();
  return {
    scrollWidth: target.scrollWidth,
    scrollHeight: target.scrollHeight,
    clientWidth: target.clientWidth,
    clientHeight: target.clientHeight,
    rect: {
      x: rect.x,
      y: rect.y,
      width: rect.width,
      height: rect.height
    },
    pageXOffset: window.pageXOffset,
    pageYOffset: window.pageYOffset
  };
}

const target = document.querySelector('#capture');
console.table(inspectTarget(target));
  • If scrollHeight is greater than clientHeight, the element has content below its visible box.
  • If the image ends exactly at the viewport, wrapper, or card boundary, look for height, max-height, overflow:hidden, or clipping on the target and its ancestors.
  • If the result shifts or loses a band proportional to the page’s current scroll position, correct scrollY and scrollX.
  • If the target is a nested scroller, page-level window dimensions alone do not expose the child’s scrollable content.

3. Match the rendering window to the intended capture

Full element or document

For a top-anchored full capture, use the content dimensions and an origin of zero:

const target = document.querySelector('#capture');
const canvas = await html2canvas(target, {
  windowWidth: target.scrollWidth,
  windowHeight: target.scrollHeight,
  scrollX: 0,
  scrollY: 0
});

windowWidth and windowHeight also affect media queries. A wider or taller rendering window can select a different responsive layout, so inspect the clone when a breakpoint changes the page structure.

Viewport or fixed-position capture

If the intended image represents the current viewport, use the actual page offset rather than forcing zero:

const canvas = await html2canvas(document.body, {
  windowWidth: window.innerWidth,
  windowHeight: window.innerHeight,
  scrollX: window.pageXOffset,
  scrollY: window.pageYOffset
});

For a fixed element, decide whether it should be captured relative to the viewport or relative to the document. A stale page offset can make a correctly sized canvas appear vertically cropped or shifted.

4. Remove constraints inside onclone

Many interfaces intentionally constrain their live layout. A dashboard panel may use height: 100% and overflow: auto; a modal may hide content with max-height; an ancestor may clip descendants with overflow: hidden. Remove only the rules that suppress content in the temporary clone.

const canvas = await html2canvas(target, {
  windowWidth: target.scrollWidth,
  windowHeight: target.scrollHeight,
  scrollX: 0,
  scrollY: 0,
  onclone: (doc) => {
    const cloneTarget = doc.querySelector('#capture');
    if (!cloneTarget) return;

    cloneTarget.style.height = `${cloneTarget.scrollHeight}px`;
    cloneTarget.style.minHeight = `${cloneTarget.scrollHeight}px`;
    cloneTarget.style.maxHeight = 'none';
    cloneTarget.style.overflow = 'visible';

    for (const ancestor of cloneTarget.parentElement
      ? cloneTarget.parentElement.closest('.capture-shell')
        ? [cloneTarget.parentElement.closest('.capture-shell')]
        : []
      : []) {
      ancestor.style.height = 'auto';
      ancestor.style.maxHeight = 'none';
      ancestor.style.overflow = 'visible';
    }
  }
});

Prefer a capture-specific class so you do not accidentally rewrite unrelated layout rules:

onclone: (doc) => {
  const style = doc.createElement('style');
  style.textContent = `
    #capture,
    #capture .capture-shell,
    #capture .scroll-region {
      height: auto !important;
      max-height: none !important;
      overflow: visible !important;
    }
  `;
  doc.head.appendChild(style);
}

Keep these edits inside onclone. Changing the live document can cause visible layout jumps, alter user interaction, or leave the application in a modified state after capture.

5. Fix nested scroll containers

A child with overflow:auto or overflow:scroll can retain its short visible box even when the outer target has a large scrollHeight. Measure the actual scrolling child and expand it in the clone.

const scroller = document.querySelector('#capture .scroll-region');
if (!scroller) throw new Error('Missing scroll region');

const canvas = await html2canvas(scroller, {
  windowWidth: scroller.scrollWidth,
  windowHeight: scroller.scrollHeight,
  scrollX: 0,
  scrollY: 0,
  onclone: (doc) => {
    const cloneScroller = doc.querySelector('#capture .scroll-region');
    if (!cloneScroller) return;
    cloneScroller.style.width = `${cloneScroller.scrollWidth}px`;
    cloneScroller.style.height = `${cloneScroller.scrollHeight}px`;
    cloneScroller.style.maxHeight = 'none';
    cloneScroller.style.overflow = 'visible';
  }
});

If the child’s content is virtualized, only the rows currently mounted in the DOM can be rendered. Expand or disable virtualization for the capture, or capture the data in sections.

6. Check responsive CSS and rendering mode

Because windowWidth participates in layout, a capture width can activate a mobile breakpoint that changes heights, hides sections, or moves content into another scroller. Compare the clone at the intended width and temporarily remove breakpoint-specific rules in onclone.

If foreignObjectRendering is enabled, repeat the capture with it disabled. An issue report describes an affected release using the actual document viewport instead of supplied dimensions in that mode; treat this as version-dependent and verify against the version you ship.

const canvas = await html2canvas(target, {
  windowWidth: target.scrollWidth,
  windowHeight: target.scrollHeight,
  scrollX: 0,
  scrollY: 0,
  foreignObjectRendering: false
});

7. Very large pages and canvas limits

A correct layout can still fail when the output exceeds browser canvas limits. Symptoms include a blank result, a hard cutoff, an exception, or an image that is smaller than the requested dimensions. The html2canvas FAQ documents browser-specific canvas limits.

Reduce the output scale, capture sections, or render tiles and stitch them in a separate step:

const sections = [...document.querySelectorAll('.capture-section')];
const canvases = [];

for (const section of sections) {
  canvases.push(await html2canvas(section, {
    windowWidth: section.scrollWidth,
    windowHeight: section.scrollHeight,
    scrollX: 0,
    scrollY: 0,
    scale: 1
  }));
}

Lowering scale reduces pixel count and memory use but also reduces output resolution. Splitting by logical sections avoids one extreme canvas and makes retries more targeted.

8. A complete diagnostic example

import html2canvas from 'html2canvas';

async function captureFullElement(selector) {
  const target = document.querySelector(selector);
  if (!target) throw new Error(`No element matches ${selector}`);

  const before = {
    scrollWidth: target.scrollWidth,
    scrollHeight: target.scrollHeight,
    clientWidth: target.clientWidth,
    clientHeight: target.clientHeight
  };
  console.table(before);

  const canvas = await html2canvas(target, {
    windowWidth: before.scrollWidth,
    windowHeight: before.scrollHeight,
    scrollX: 0,
    scrollY: 0,
    foreignObjectRendering: false,
    onclone: (doc) => {
      const clone = doc.querySelector(selector);
      if (!clone) throw new Error(`Clone is missing ${selector}`);

      clone.style.height = `${clone.scrollHeight}px`;
      clone.style.maxHeight = 'none';
      clone.style.overflow = 'visible';

      const parent = clone.parentElement;
      if (parent) {
        parent.style.height = 'auto';
        parent.style.maxHeight = 'none';
        parent.style.overflow = 'visible';
      }
    }
  });

  if (!canvas.width || !canvas.height) {
    throw new Error('html2canvas returned an empty canvas');
  }
  return canvas;
}

const canvas = await captureFullElement('#capture');
const link = document.createElement('a');
link.download = 'capture.png';
link.href = canvas.toDataURL('image/png');
link.click();

9. Troubleshooting table

Symptom Likely cause Fix
Bottom ends at the browser viewport Short windowHeight or a viewport-sized wrapper Set windowHeight to the target’s scrollHeight; remove clone height and max-height limits.
Bottom ends at a card or panel edge overflow:hidden, contain, or clipping on an ancestor Inspect ancestors and set capture-only overflow to visible in onclone.
Only a nested list is cropped Child scroll container remains constrained Measure the child’s dimensions and expand that child in the clone.
Capture starts below the top Nonzero or stale scrollY Use scrollY: 0 for a top-anchored full capture, or pass the intended offset.
Layout changes to mobile windowWidth activates a media query Use the intended width and inspect breakpoint rules in the clone.
Changing onclone has no visible effect Rule is applied to the live document only, or the selector is absent in the clone Query the cloned document and set inline styles or a clone-only stylesheet.
foreignObjectRendering still crops Version-specific viewport handling Retry with foreignObjectRendering: false and compare versions.
Very tall output is blank or cut off Browser canvas area or dimension limit Lower scale, split into sections, or tile the capture.
Images or fonts change the measured height Capture starts before assets finish loading Wait for required images and fonts before measuring and calling html2canvas.

10. A practical capture checklist

  • Measure scrollWidth, scrollHeight, clientWidth, and clientHeight before capture.
  • Measure again inside onclone if the callback changes layout.
  • Set the rendering window to the content dimensions for full captures.
  • Use scrollY: 0 when the image should start at the document or element top.
  • Inspect every ancestor for fixed height, max-height, overflow, clipping, or containment.
  • Expand nested scrollers separately.
  • Check media queries at the chosen windowWidth.
  • Compare foreignObjectRendering on and off when dimensions behave unexpectedly.
  • Reduce scale or split the capture when the canvas is extremely large.

11. Or skip the browser setup

If you need a reliable screenshot endpoint instead of maintaining browser-side layout workarounds, ScreenshotNeo accepts one GET request and returns a PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. An MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.

See the ScreenshotNeo API documentation for all options.

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(`Screenshot failed: ${res.status}`);
const data = Buffer.from(await res.arrayBuffer());
require('node:fs').writeFileSync('shot.webp', data);

ScreenshotNeo includes full-page capture with lazy images loaded, CSS selector element capture, custom CSS and JavaScript, waits, request blocking, headers, cookies, user agents, authorization, timezone and geolocation, device presets, retina scale, resizing, caching with a chosen TTL, signed links, asynchronous webhooks, bulk capture, PDF controls, HTML/CSS rendering, and a usage API. Clean shots are the only billed captures. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000.

Create a free ScreenshotNeo account and start with 1,000 screenshots a month at no cost.

12. Performance, reliability, and cost notes

  • Measure once, capture once: avoid repeated layout reads after mutating the clone. Prepare the capture CSS in one callback.
  • Wait for stable content: lazy images, web fonts, animations, and expanding components can change scrollHeight after measurement. Disable transitions for the clone when a stable frame matters.
  • Keep scale proportional: doubling both dimensions at a higher scale multiplies pixel count and memory use.
  • Retry by failure type: a layout crop needs different handling from a canvas-limit failure. Log dimensions, options, and the browser error with each attempt.
  • Control remote assets: cross-origin images and fonts can require appropriate CORS headers; a missing asset can change the apparent height or produce a tainted canvas.
  • Use caching carefully: if the page changes frequently, stale screenshots are a correctness issue. For API captures, choose a TTL that matches the page’s update rate.

13. FAQ

Does onclone change my live page?

No. It edits the temporary document used for rendering. Keep capture-only styles there so the live interface remains unchanged.

Why does increasing only windowHeight sometimes fail?

A nested scroller or ancestor can still clip the content. Expand the constrained element and its relevant ancestors in the clone.

Should scrollY always be zero?

Use zero for a top-anchored full capture. Use the actual intended offset when reproducing a viewport or a scrolled position.

Can html2canvas capture content that is not mounted?

No. Virtualized lists and collapsed sections must be expanded or captured in mounted sections first.

When should I split a capture?

Split it when the canvas is so large that the browser cuts it off, returns a blank image, or throws a dimension or memory error.