Someone eventually tries to capture your app. A user filing a bug, a support agent writing a ticket, a PM building a deck, a customer archiving an invoice. They press whatever full-page screenshot tool they have, and they get something wrong: a nav bar repeated nine times, a table with six rows in it, a page that is mostly grey boxes.
They will not file that as a bug against you. They will just send the broken image, and whoever receives it will misunderstand the problem.
Full-page capture works by scrolling the document, photographing the viewport at each step, and stitching the slices together. Almost everything below is a case where that loop sees something different from what a human sees.
1. position: fixed and position: sticky
A fixed header is present in every slice, so it appears once per viewport height in the stitched image. Sticky is the same story with extra edge cases at its stuck/unstuck boundary.
Good capture tools detach these for the duration and put them back. Not all of them do, and none of them can guess which of your elements is decorative chrome and which is content.
2. Virtualized lists
This is the one that cannot be worked around from outside.
If you render 20 of 10,000 rows and swap them as the user scrolls - react-window, TanStack Virtual, ag-grid, your own implementation - then rows 21 through 10,000 do not exist in the DOM at any moment. There is no scroll position at which they can be photographed. A capture of your table is a capture of whatever was mounted.
Nothing on the tool side fixes this. The page has to offer a non-virtualized rendering path.
// something a capture tool or a user can actually reach
if (location.search.includes('render=full')) {
virtualizer.setOptions({ overscan: rows.length })
}
Even a "print view" that renders everything is enough.
3. Lazy-loaded images
IntersectionObserver fires when the image enters the viewport, then the image has to arrive over the network and lay out. A capture loop that scrolls and photographs immediately wins that race and records the placeholder.
The tool should wait. Many don't, and some can't tell the difference between "still loading" and "intentionally blank".
Native loading="lazy" behaves better here than a hand-rolled observer, because browsers already pre-load nearby images and print paths know about it.
4. Inner scroll containers
A capture scrolls the document. A div with overflow: auto is a separate scroll context, and it is not the document.
So the page around it captures fully, and the panel inside it captures whatever slice happened to be visible. Code blocks with max-height, chat panes, data tables with a frozen header, sidebars - all of them silently truncate.
If a region is content rather than a viewport-sized widget, consider letting it grow and letting the page scroll instead.
5. 100vh app shells
.app { height: 100vh; display: grid; grid-template-rows: auto 1fr }
.main { overflow-y: auto }
This is a completely normal layout, and it means your document never scrolls. From the outside, your entire application is one viewport tall. A full-page capture of it returns one screen - correctly, because that is the whole document.
Everything real is inside .main, which is case 4.
I ran into this building a demo page for a capture tool: the page had a sticky sidebar and a viewport-height shell, and captures of it kept coming out with a 1,400px empty column. The page was fine. The layout was just not a document.
6. scroll-behavior: smooth
Set globally, it turns every programmatic scrollTo into an animation. A capture loop that scrolls and immediately photographs gets a frame from the middle of the animation, and every slice lands at the wrong offset.
Scope it to user-initiated navigation rather than applying it to html unconditionally, or respect the reduced-motion query, which most capture paths and many users share:
@media (prefers-reduced-motion: no-preference) {
html { scroll-behavior: smooth }
}
7. Scroll-triggered animation
Fade-in-on-scroll means an element's opacity depends on when it was observed. During a fast programmatic scroll, some elements are caught mid-transition and some never trigger at all. The stitched image gets a page with randomly half-faded sections.
Same reduced-motion escape hatch applies.
8. Canvas and WebGL
A WebGL context created without preserveDrawingBuffer: true may be cleared before anything outside the render loop can read it, so it can come back blank or stale depending on the capture path. If a chart matters to your users, it is worth checking what it looks like in a capture rather than assuming.
A test that takes one minute
Open your own app. Use the built-in browser feature - no extension needed:
-
Chrome: DevTools, Ctrl+Shift+P, type
screenshot, choose Capture full size screenshot - Firefox: right-click the page, Take Screenshot -> Save full page
Then check four things:
| Check | Failure looks like |
|---|---|
| Header count | Nav bar appears more than once |
| Image content | Grey placeholders below the fold |
| Long lists | A table with only the visible rows |
| Page height | The capture is exactly one screen tall |
If the last row is true, you have case 5 and everything else is moot.
Why bother
Screenshots of your product are how it gets described to people who have not used it - in bug reports, in internal decks, in support threads, in a review someone posts. If those images are broken, the description is broken, and you are not in the room to correct it.
Fixing this is mostly not new code. It is a print/export path, one query parameter that disables virtualization, and not putting scroll-behavior: smooth on html.
Written while working on OpenCapture, a free and open-source (AGPL-3.0) full-page screenshot extension for Chrome, Firefox and Edge. Source is on GitHub if you want to see how any of the above is handled in practice.
Top comments (0)