Handling sticky headers in full-page screenshots

This page is part of the responsive viewport testing section of the visual regression guide, which covers what a capture actually contains at a given width.

The specific problem: a full-page screenshot of a page with a sticky header contains the header four times — once per scroll step — and the count changes whenever the page’s length changes, so an unrelated copy edit produces a large diff.

Problem statement

A fullPage capture is not one photograph. The browser cannot render an arbitrarily tall image in one pass, so it scrolls the document in viewport-sized steps, captures each, and stitches them together. Anything that repositions itself on scroll is painted once per step.

The symptom is a stitched image with a repeated header — and, worse, a repeat count that depends on document height. Adding a paragraph anywhere on the page adds a scroll step, adds a header copy, and shifts every pixel below it, so a one-line copy change produces a diff measured in whole percent.

Root cause

position: sticky and position: fixed are defined relative to the scroll position. During a stitched capture the scroll position changes several times, and each time the element re-anchors to the new viewport top. The stitched result is therefore an image of something that never existed on screen.

The fix depends on what the snapshot is for, and the mistake most often made is to reach for a mask — which paints over the header copies, hides genuine header regressions, and leaves the shifted content below still diffing.

Why the header appears several times Four bands. A full-page capture is taken as a series of viewport-sized segments. The header is painted at the top of the first, and again part-way down the second and third, both highlighted, because it re-sticks after each scroll. The stitched result contains one copy of the header per scroll step rather than one at the top. What a fullPage capture does to a sticky element Viewport 1 header painted at the top Viewport 2 header painted again, mid-page Viewport 3 and again Stitched image one header per scroll step The count depends on document height, so it changes whenever content does

Minimal reproduction

.site-header { position: sticky; top: 0; z-index: 10; }
// the stitched image contains one header per scroll step
await expect(page).toHaveScreenshot('checkout.png', { fullPage: true });

Step-by-step fix

1. Decide what the snapshot is actually for

Four intents, four techniques A decision node asking what is actually being captured. To capture the header itself, take a locator screenshot scoped to it. To capture the page content, unstick the header during capture by overriding its position. To capture the sticky behaviour, take two viewport-sized shots, one at the top and one scrolled. To capture everything in one image, accept the repeated headers and pin the document height so their number is stable. What are you actually trying to capture? Scope to the header a locator screenshot the header itself Unstick during capture position: static the page content Two viewport shots top and scrolled the sticky behaviour Accept the repeats and pin the height everything, one image

Most full-page snapshots exist to check the page’s content and layout, not its scroll behaviour. If that is the case, the header being sticky is incidental to what you are capturing, and unsticking it during capture is the correct answer rather than a workaround.

2. Unstick during capture only

// injected for the capture; never shipped
await page.addStyleTag({
  content: `
    .site-header, [data-sticky] {
      position: static !important;
      top: auto !important;
    }
  `,
});
await expect(page).toHaveScreenshot('checkout.png', { fullPage: true });

What this does: the header is painted once, at the top, where it appears in the document flow. The image now contains what the page is, rather than an artefact of how it was photographed.

3. Use stylePath so every capture gets it

// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: { stylePath: './test/screenshot.css', animations: 'disabled' },
  },
});

What this does: moves the override out of individual tests, so a capture written next month inherits it without its author having to know.

4. Capture the sticky behaviour deliberately, in its own test

test('header sticks on scroll', async ({ page }) => {
  await page.goto('/checkout');
  await page.mouse.wheel(0, 800);
  await page.waitForTimeout(0);                 // let the scroll settle
  // a viewport shot, not fullPage — this is about what is on screen
  await expect(page).toHaveScreenshot('checkout-scrolled.png');
});

What this does: puts the behaviour under test where it can actually be observed — in a viewport-sized image showing the header over the content — instead of as a side effect of a stitched capture.

5. Prefer scoped captures over full-page ones

// most component-level snapshots want an element, not a document
await expect(page.locator('main')).toHaveScreenshot('checkout-main.png');

What this does: removes the stitching problem entirely for the majority of captures, and produces a smaller, more stable image whose diff is easier to read.

Verification

Unsticking removes the repeats and the false diff A repeated run of a checkout page capture. Before the fix the stitched image contains four copies of the header, rising to five after an unrelated copy edit lengthened the page, producing a 6.20 percent diff. After unsticking the header during capture, the image contains one header and three runs report zero difference. playwright test --repeat-each=3 --grep @fullpage before: Checkout fullPage 4 header copies, then 5 after a copy edit diff 6.20% on an unrelated change after unsticking during capture: 1 header, 0.00% across 3 runs

Count the headers in the generated baseline. One means the override is applied; more than one means the selector missed — usually because the sticky element is a wrapper rather than the header itself, or because a framework applies the position inline where a stylesheet cannot override it without !important.

Edge cases and caveats

  • Inline styles. A header positioned by JavaScript writing style.position cannot be overridden by a stylesheet without !important, and sometimes not even then. Set the property directly on the element in an init script instead.

  • position: fixed overlays. Cookie banners, chat widgets and toasts have the same problem and are usually not part of what you meant to capture at all. Hiding them during capture is more honest than letting them appear several times.

  • Scroll-linked animations. A header that shrinks on scroll will be captured at different sizes in different segments. Unsticking removes the reposition but not the size change; disabling the scroll listener during capture does.

  • Very tall pages. Even without sticky elements, a stitched capture of a very long page is slow and produces an image nobody reads at full size. Capturing sections separately is usually more useful than one enormous file.

FAQ

Should I just mask the header?

No — masking treats the symptom and creates two new problems. The header copies are painted over, so genuine header regressions stop being caught, and the content below them is still shifted by however many scroll steps the document happens to have, so the diff persists. Unsticking removes the cause, leaving both the header and the content under comparison.

Does unsticking change what the page looks like to a user?

Not in the shipped page — the override lives in a stylesheet injected for the capture only. What changes is the image: the header appears at the top of the document rather than floating over the content. That is a fair representation of the page’s structure, and the scroll behaviour is covered by its own test.

Why does the diff percentage jump so much when the page grows?

Because a new scroll step inserts a header copy part-way down the image, and everything below that point shifts by the header’s height. A pixel comparator sees a large fraction of the image as changed even though the visible content is nearly identical. It is the clearest illustration of why a stitched capture of a sticky layout is unstable by construction rather than merely noisy.