<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tobby</title>
    <description>The latest articles on DEV Community by Tobby (@tobbytools).</description>
    <link>https://dev.to/tobbytools</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4192739%2F95b7b3a5-804f-4ad5-950d-5d43b2e38d57.png</url>
      <title>DEV Community: Tobby</title>
      <link>https://dev.to/tobbytools</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tobbytools"/>
    <language>en</language>
    <item>
      <title>Full-page screenshots that are actually complete: lazy loading, sticky headers, 100vh layouts and emoji</title>
      <dc:creator>Tobby</dc:creator>
      <pubDate>Sun, 18 Oct 2026 19:10:53 +0000</pubDate>
      <link>https://dev.to/tobbytools/full-page-screenshots-that-are-actually-complete-lazy-loading-sticky-headers-100vh-layouts-and-4aj</link>
      <guid>https://dev.to/tobbytools/full-page-screenshots-that-are-actually-complete-lazy-loading-sticky-headers-100vh-layouts-and-4aj</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build and sell one of the three screenshot tools compared below, a hosted screenshot Actor on Apify. This article was written with the help of AI; the numbers come from the public benchmark data linked at the end, and everything here works in a plain Playwright or Puppeteer script.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;page.screenshot({ fullPage: true })&lt;/code&gt; looks done until you look at the output: blank rectangles where lazy images should be, the sticky header stamped down the page, a cookie banner across the middle, and emoji as tofu boxes. Here is what broke when I captured 30 real sites, and what fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Lazy-loaded images: scroll first
&lt;/h2&gt;

&lt;p&gt;Images below the fold load only when they come close to the viewport. Scroll the whole page in steps, wait for pending images, then go back to the top before capturing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scrollHeight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scrollTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForFunction&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;images&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;every&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="c1"&gt;// a broken image should not fail the whole capture&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cap the scrolling (I stop at 30,000 px) or infinite feeds will scroll forever, and &lt;em&gt;record&lt;/em&gt; that you hit the cap, so nobody mistakes a truncated page for a complete one.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Sticky and fixed elements
&lt;/h2&gt;

&lt;p&gt;Fixed headers repeat on every slice of a stitched screenshot, and fixed layers get painted in the wrong place. What worked for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;position: sticky&lt;/code&gt; → force &lt;code&gt;position: relative&lt;/code&gt;. At scroll position 0 the element sits in its natural spot anyway.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;fixed&lt;/code&gt; bar at the top of the viewport → switch it to &lt;code&gt;absolute&lt;/code&gt; at its current coordinates, so it appears once, at the top.&lt;/li&gt;
&lt;li&gt;Other &lt;code&gt;fixed&lt;/code&gt; layers (chat bubbles, back-to-top buttons, bottom bars, modal backdrops with a huge &lt;code&gt;z-index&lt;/code&gt;) → hide them.&lt;/li&gt;
&lt;li&gt;Check again right before the capture. Some headers only become &lt;code&gt;fixed&lt;/code&gt; after you scroll, and some "please disable your ad blocker" modals show up late.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Capture speed and 100vh layouts
&lt;/h2&gt;

&lt;p&gt;Chrome can capture beyond the viewport, but on Wikipedia's long &lt;em&gt;Emoji&lt;/em&gt; article that took about &lt;strong&gt;14 seconds per slice&lt;/strong&gt;. So by default I resize the viewport to the page height (in slices of up to 8,000 px) and capture what is on screen.&lt;/p&gt;

&lt;p&gt;That breaks on layouts built from &lt;code&gt;100vh&lt;/code&gt; sections: make the viewport 8,000 px tall and a "full-screen" hero becomes 8,000 px tall too. When the page height changes on resize, I fall back to scrolling a normal-size viewport and stitching the slices. Pages where &lt;code&gt;body&lt;/code&gt;, not the window, is the scrolling element (getemoji.com is one) need yet another path.&lt;/p&gt;

&lt;p&gt;Here is the bottom half of vercel.com, which uses that kind of layout, captured by three tools (left to right: mine, apify/screenshot-url, cheapapi/website-screenshot-api). The middle one repeats the fixed hero over the footer; the right one is blank in the lower half:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6j95ns04q1bc0c2zvgp7.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6j95ns04q1bc0c2zvgp7.jpg" alt="vercel.com full-page captures from three tools" width="800" height="1121"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Emoji and CJK fonts in containers
&lt;/h2&gt;

&lt;p&gt;Slim Docker images have no colour-emoji font, so 😀 becomes ☐, and Japanese or Chinese text turns into boxes. Two Debian packages fix it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; fonts-noto-color-emoji fonts-noto-cjk &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; fc-cache &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgu1mkq6vbj9vruvexr2e.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgu1mkq6vbj9vruvexr2e.jpg" alt="getemoji.com captured by three tools" width="800" height="720"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Cookie walls and bot checks
&lt;/h2&gt;

&lt;p&gt;Consent banners can be dismissed: block the consent manager's script, or click its "accept" button before capturing. CAPTCHAs are different. I don't try to bypass them; I detect the challenge page and report an error instead of a picture of it. (Careful: many normal pages load an &lt;em&gt;invisible&lt;/em&gt; reCAPTCHA badge. Only treat a visible challenge as a block, or you will flag perfectly good pages. I learned that one this week.)&lt;/p&gt;

&lt;h2&gt;
  
  
  How three tools did on 30 sites
&lt;/h2&gt;

&lt;p&gt;The same 30 pages (Stripe, The Guardian, BBC, Wikipedia, Vercel, getemoji, Japanese Wikipedia, Spiegel, IKEA and others), one run per tool on 18 Oct 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;mine&lt;/th&gt;
&lt;th&gt;apify/screenshot-url&lt;/th&gt;
&lt;th&gt;cheapapi&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Complete and visually correct&lt;/td&gt;
&lt;td&gt;24/30&lt;/td&gt;
&lt;td&gt;19/30&lt;/td&gt;
&lt;td&gt;18/30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full page captured (28 reachable pages)&lt;/td&gt;
&lt;td&gt;27/28&lt;/td&gt;
&lt;td&gt;25/28&lt;/td&gt;
&lt;td&gt;21/28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bot-blocked pages (medium.com, unsplash.com)&lt;/td&gt;
&lt;td&gt;2 errors, nothing billed&lt;/td&gt;
&lt;td&gt;1 challenge-page image, 1 no output&lt;/td&gt;
&lt;td&gt;2 challenge-page images&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Caveats: one run each, with an earlier build of mine; 13 of the 30 rows were judged on captured height vs. true page height only, without visual inspection; the apify run was stopped at 29/30 for budget. Still imperfect in mine: booking.com shows grey skeleton placeholders, and The Verge has an empty ad band.&lt;/p&gt;

&lt;p&gt;Per-site verdicts, run IDs, heights and the URL list are in the benchmark folder: &lt;a href="https://github.com/tobbytools/website-screenshot-examples/tree/main/benchmark" rel="noopener noreferrer"&gt;github.com/tobbytools/website-screenshot-examples/tree/main/benchmark&lt;/a&gt;. The repo also has MIT-licensed calling examples for my tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which sites break your screenshots?&lt;/strong&gt; Drop them in the comments and I'll add them to the next test run.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>playwright</category>
      <category>javascript</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
