Mobile layout bugs are often not CSS bugs alone. They show up when the browser UI changes height, when the address bar collapses, when a notch adds safe-area padding, or when 100vh means something different from what the designer expected. If your test only checks screenshots, you can end up chasing harmless pixel drift while missing the real regression.

The better approach is to test the state that drives the layout, then assert the computed styles and scroll behavior that should follow from that state. That means checking viewport-related CSS values, safe-area handling, and a small set of DOM conditions that are stable across devices.

The main goal is not to prove that every pixel is identical. The goal is to prove that the page responds correctly when the browser viewport changes.

Start with the terminology that actually matters

Three concepts get mixed together a lot:

  • Layout viewport, the size used for CSS layout calculations.
  • Visual viewport, the part of the page currently visible after browser chrome, pinch zoom, or on-screen UI are accounted for.
  • Dynamic viewport units, dvh, svh, and lvh, which are designed to react to mobile browser UI changes more predictably than older viewport unit behavior.

If you want a primary source, the CSS Values and Units spec documents the viewport unit family, and the MDN pages for dvh, svh, and lvh summarize the practical differences. Safe-area padding is exposed through the CSS environment variables documented by WebKit and standardized through env(), with the most common mobile use being env(safe-area-inset-top/right/bottom/left).

The key testing implication is simple:

  • 100vh can be a weak assertion target on mobile.
  • 100dvh usually matches the active viewport better when browser chrome expands or collapses.
  • Safe-area values are not a fixed device constant, they depend on device, orientation, browser, and whether the browser UI is visible.

What to assert instead of screenshots

For these bugs, layout assertions should answer four questions:

  1. Did the element use the expected viewport unit or fallback?
  2. Did the browser report the expected viewport dimensions after chrome changed?
  3. Did the safe-area padding get applied where it should?
  4. Did the page remain scrollable or fixed in the intended way?

That leads to three stable assertion layers.

1) Computed style assertions

Assert the computed value of the element that depends on viewport math. This tells you whether the CSS resolved as intended, without requiring a pixel-perfect screenshot.

const hero = page.locator('[data-testid="hero"]');
await expect(hero).toHaveCSS('min-height', /dvh|vh/);
await expect(hero).toHaveCSS('padding-bottom', /env\(|px/);

For stricter checks, inspect the actual computed style and compare it to the intended design rule.

const paddingBottom = await hero.evaluate((el) =>
  getComputedStyle(el).paddingBottom
);
expect(paddingBottom).not.toBe('0px');

A computed-style check is better than a screenshot when the failure mode is “the page stopped using the right CSS rule.” It is not enough when the problem is a nested container clipping content, so pair it with DOM and scroll checks.

2) Viewport state assertions

If the behavior should change when browser chrome collapses or expands, assert the viewport state directly. In Playwright, you can read both the window metrics and the visual viewport.

const viewport = await page.evaluate(() => ({
  innerHeight: window.innerHeight,
  visualHeight: window.visualViewport?.height,
  visualOffsetTop: window.visualViewport?.offsetTop
}));

expect(viewport.innerHeight).toBeGreaterThan(0); expect(viewport.visualHeight).toBeGreaterThan(0);

window.visualViewport is especially useful when debugging mobile browser UI changes because it reflects the visible area more closely than older layout-only checks. MDN documents the VisualViewport API, including its size and offset properties.

3) Scroll behavior assertions

Many mobile regressions are really scroll regressions. A fixed hero, an overlay, or a sticky footer can trap the page, hide controls behind chrome, or create a dead zone.

Useful checks include:

  • the document can scroll when content exceeds the viewport,
  • focus moves to the intended element without jumping unexpectedly,
  • the bottom action is still reachable after browser chrome changes.
await page.locator('body').evaluate((body) => {
  body.scrollTop = 1000;
});
const scrolled = await page.evaluate(() => window.scrollY);
expect(scrolled).toBeGreaterThan(0);

A practical test matrix for mobile viewport bugs

You do not need every device permutation. You need the combinations that change the layout math.

Scenario What changes What to assert Why it matters
Default mobile load Browser chrome visible hero height, safe-area padding, first meaningful content visible Catches initial render mistakes
After scrolling down Chrome often collapses visualViewport.height, sticky/footer placement Catches vh vs dvh mismatches
After scrolling back up Chrome may expand again layout returns without overlap or clipping Catches state reset bugs
Portrait to landscape Safe areas and height change padding and content alignment Catches device-specific cutoffs
Notched device safe-area-inset-* applied edge padding, bottom actions not obscured Catches iPhone-style notch issues

If your app targets iOS Safari, this matrix matters more than adding another screenshot threshold. The browser chrome behavior is part of the test input, not noise.

Example: testing a hero that should use 100dvh

Suppose the hero should always fill the visible viewport on mobile, and the action button should remain reachable when the address bar collapses.

<section data-testid="hero" class="hero">
  <h1>Welcome</h1>
  <button data-testid="cta">Start</button>
</section>
.hero {
  min-height: 100dvh;
  padding-bottom: env(safe-area-inset-bottom, 0px);
}

A focused test can check the intended CSS, then verify that the CTA is still reachable after scrolling.

import { test, expect } from '@playwright/test';
test('hero fills the visible viewport and keeps CTA reachable', async ({ page }) => {
  await page.goto('/');

  const hero = page.locator('[data-testid="hero"]');
  await expect(hero).toHaveCSS('min-height', /dvh/);

  await page.mouse.wheel(0, 800);
  await expect(page.locator('[data-testid="cta"]')).toBeVisible();
});

This test is not trying to prove exact pixel heights. It is checking the contract that matters, the hero should track the visible viewport and the CTA should not disappear under browser UI.

Safe-area inset testing without brittle device-specific values

Safe-area insets are easy to overfit. If you hardcode a top or bottom value, your test may fail on a device where the browser reports a different inset, even though the page is correct.

A better pattern is to test for the presence of safe-area-aware styling and the absence of clipping or overlap.

Good assertions

  • The element uses env(safe-area-inset-*) or a fallback wrapper.
  • A fixed bottom bar does not cover primary content.
  • Tappable controls stay inside the usable area.
  • The page still scrolls enough to expose hidden content if needed.

Avoid these assertions

  • Exact pixel equality for the inset value.
  • Screenshot diffs that fail on every browser chrome change.
  • Assuming all notched devices expose the same inset in portrait and landscape.

If you need to confirm the browser exposes safe-area values, read the computed padding rather than the raw environment variable. The variable itself is not directly inspectable from page JavaScript in a portable way, but the computed style is.

const footer = page.locator('[data-testid="sticky-footer"]');
const paddingBottom = await footer.evaluate((el) =>
  getComputedStyle(el).paddingBottom
);
expect(paddingBottom).not.toBe('0px');

Browser chrome changes are a state transition problem

Mobile browser chrome collapsing or expanding is not just a viewport resize. It can change the visible area, move content, and alter the scroll position the user sees. That means tests should model the transition, not only the final state.

A useful pattern is:

  1. load the page at the top,
  2. capture initial viewport metrics,
  3. scroll enough to trigger chrome collapse,
  4. capture viewport metrics again,
  5. verify the expected layout survived the transition.
const before = await page.evaluate(() => ({
  height: window.visualViewport?.height,
  scrollY: window.scrollY
}));
await page.mouse.wheel(0, 1200);
const after = await page.evaluate(() => ({
  height: window.visualViewport?.height,
  scrollY: window.scrollY
}));

expect(after.scrollY).toBeGreaterThan(before.scrollY); expect(after.height).toBeGreaterThan(0);

You may not get deterministic size deltas across browsers, and that is fine. The useful assertion is that the page survived the transition and the visible viewport remains valid for interaction.

Where visual snapshots still help

Visual snapshots are still useful when the regression is about obvious overlap, clipped backgrounds, or a footer sitting on top of content. The mistake is using them as the only assertion for a dynamic viewport problem.

Use snapshots for:

  • gross overlap checks,
  • unintended blank space,
  • alignment regressions that are truly visual.

Do not rely on them alone for:

  • vh vs dvh behavior,
  • safe-area padding correctness,
  • browser chrome collapse handling,
  • scrollability after viewport changes.

That split keeps visual tests valuable without turning them into flaky height detectors.

Debugging checklist when a test fails

When a layout assertion fails on mobile, inspect these in order:

  1. Which viewport unit is in the CSS? If it is vh, confirm whether dvh is required.
  2. What does visualViewport.height report before and after scroll? This tells you whether the browser chrome changed the visible area.
  3. Is safe-area padding applied through computed styles? If not, the edge content may be clipped.
  4. Can the page still scroll and focus the intended control? If not, you may have a fixed-container problem rather than a viewport unit problem.
  5. Is the test asserting the wrong thing? A screenshot might be failing because the browser UI changed, not because the app broke.

If the test output only says “screenshot mismatch,” you do not yet know whether the bug is CSS, browser chrome, or a bad assertion.

A decision rule that keeps tests maintainable

Use this simple rule when choosing assertions for mobile viewport behavior:

  • If the regression is about which CSS rule is active, assert computed style.
  • If the regression is about what the browser says the visible area is, assert viewport metrics.
  • If the regression is about whether the user can still use the page, assert scroll and interaction behavior.
  • If the regression is about unexpected overlap or clipping, add a screenshot only as a secondary signal.

That order keeps the tests close to the failure mode. It also makes failures easier to diagnose, which matters more than chasing perfect visual fidelity.

FAQ

Is 100dvh always better than 100vh?

No. 100dvh is usually a better fit for mobile UI that should track the visible viewport, but it is still a design choice. Some layouts intentionally want a stable height that does not change as browser chrome moves.

How do I test safe-area insets on a desktop browser?

You usually cannot reproduce real safe-area behavior on desktop in a meaningful way. Use a real mobile browser when the bug depends on notches or browser chrome, and assert computed padding plus usability rather than exact inset values.

Should I mock window.visualViewport?

Only for isolated unit-style logic. For browser automation, prefer real viewport behavior from a mobile browser because the bug is about browser UI and rendering, not just JavaScript state.

Are screenshot tests useless for this problem?

No. They are just not sufficient by themselves. Use them after you have already asserted the viewport unit, the computed styles, and the scroll behavior that explain the layout.

What is the most reliable thing to assert first?

The computed style on the element that depends on viewport math. It is the fastest way to tell whether the layout is using the intended CSS rule before you debug browser-specific behavior.