A hidden panel can fail in three different ways, and those failures do not look the same to browser automation. An element might exist in the DOM but not be painted yet, it might be offscreen and therefore skipped by lazy rendering, or it might be mounted but only partially hydrated. If your test only checks for presence, you can miss all three.

For teams trying to test content-visibility in browser automation, the useful question is not “is the element there?” It is “what state is it in, and what user-visible behavior should be true at that state?” That distinction matters for content-visibility:auto, intersection-driven rendering, and conditional hydration.

The three states that matter

Before writing tests, separate the failure modes:

  • Not mounted: the element does not exist in the DOM yet.
  • Mounted but not painted: the element exists, but the browser is allowed to skip rendering work until it becomes relevant.
  • Mounted but partially initialized: the element is visible enough to exist and maybe even be painted, but event handlers, data, or client-side hydration are not complete.

content-visibility:auto is useful because it allows the browser to skip rendering work for offscreen content. The browser may defer layout and paint for those subtrees until they are near the viewport. That is a performance feature, but it creates test ambiguity if you assume “DOM presence” means “ready for interaction.”

A good test for lazy rendering checks behavior at the boundary, not just after the UI has fully loaded.

Build a small demo that exposes all three states

Use one page with a fixed hero, a long spacer, and a card section that loads in two steps. The section uses content-visibility:auto, an IntersectionObserver, and delayed hydration.

<section id="feed">
  <article class="card" data-testid="card-1">
    <h2>First card</h2>
    <button data-testid="action-1">Like</button>
  </article>

Second card

loading...

</section>

.card {
  min-height: 240px;
  margin: 24px 0;
  border: 1px solid #ddd;
  padding: 16px;
}

.lazy { content-visibility: auto; contain-intrinsic-size: 240px; }

const target = document.querySelector('[data-testid="card-2"]') as HTMLElement;
const button = target.querySelector('[data-testid="action-2"]') as HTMLButtonElement;
const status = target.querySelector('[data-testid="status-2"]') as HTMLElement;

const io = new IntersectionObserver(([entry]) => { if (!entry.isIntersecting) return;

setTimeout(() => { button.disabled = false; status.textContent = ‘ready’; button.addEventListener(‘click’, () => { status.textContent = ‘clicked’; }); }, 300); });

io.observe(target);

This demo gives you a reproducible surface for three test expectations:

  1. The card is not yet in the DOM.
  2. The card is in the DOM but not ready to interact with.
  3. The card is visible and initialized, but the click handler may still be missing if hydration is incomplete.

What fails, and why

A few assertions look fine until they hit a lazy subtree.

1) Presence is not readiness

await expect(page.locator('[data-testid="card-2"]')).toHaveCount(1);

This only proves that the element exists. It does not prove the browser has painted it, the app has hydrated it, or the button is enabled.

2) Visibility is not initialization

await expect(page.locator('[data-testid="card-2"]')).toBeVisible();

Visibility is better, but still incomplete. A button may be visible and still disabled. A status label may still say loading.... A click can pass through to the DOM and do nothing if listeners have not been attached yet.

3) Clickability is not business readiness

await page.locator('[data-testid="action-2"]').click();
await expect(page.locator('[data-testid="status-2"]')).toHaveText('clicked');

This is the most useful assertion, but only if the app’s readiness model is explicit. If the button becomes enabled before hydration finishes, the click can race with the listener attachment and produce a false failure.

A better assertion strategy

Use one assertion per state transition:

  • Mounted: the node exists.
  • Rendered enough: the node is visible or the browser has scrolled it into the viewport.
  • Ready: a stable app signal says the component is initialized.
  • Interactive: a real user action changes state.

In practice, that means testing against a readiness indicator, not a layout accident.

import { test, expect } from '@playwright/test';
test('lazy card becomes interactive only after hydration', async ({ page }) => {
  await page.goto('http://localhost:3000');

  const card = page.locator('[data-testid="card-2"]');
  const button = page.locator('[data-testid="action-2"]');
  const status = page.locator('[data-testid="status-2"]');

  await expect(card).toHaveCount(1);
  await card.scrollIntoViewIfNeeded();
  await expect(status).toHaveText('ready');
  await expect(button).toBeEnabled();

  await button.click();
  await expect(status).toHaveText('clicked');
});

The key detail is the explicit status label. If the app says “ready,” the test can wait on that instead of guessing based on paint timing or DOM shape.

Why browser automation gets this wrong

Automation frameworks do not model rendering exactly the way a human does. A locator can resolve before the browser paints a subtree. A button can be enabled before hydration completes. A scroll operation can bring an element near the viewport, but not guarantee the app’s observer fired.

That means the classic test smell is this pattern:

await expect(locator).toBeVisible();
await locator.click();

It works until one of these changes:

  • the element is inside a content-visibility:auto subtree,
  • the app defers initialization with requestAnimationFrame, setTimeout, or network data,
  • the visible card is a skeleton, not the final interactive component,
  • the click handler is attached only after an observer callback.

Reproducible failure cases to add to your demo

If you want a demo app that surfaces hidden-state bugs quickly, add these toggles:

Case 1: DOM exists, but button is disabled

Use this to prove that visibility is not enough. Your test should assert the disabled state before trying to click.

Case 2: Component becomes visible before hydration

Render the label text immediately, then attach listeners later. This catches tests that rely on text alone.

Case 3: Observer fires only after scroll

Keep the component far below the fold. Scroll to it in the test, then wait for a concrete readiness signal.

Case 4: Partial server render, delayed client behavior

Server-render the shell, then hydrate the action area later. This catches frameworks that let the page appear ready before the controls work.

A simple checklist for lazy rendering tests

Use this ordering when you build assertions:

  1. Confirm the node exists.
  2. Scroll it into view if the feature depends on being visible.
  3. Wait for a component-specific ready signal.
  4. Assert enablement or accessibility state.
  5. Trigger the user action.
  6. Verify the state change, not just the click.

A ready signal can be a text label, an ARIA state, a data attribute, or a network completion marker. The important part is that it is owned by the application, not inferred from the browser.

Good and bad signals

Signal Useful? Why
toBeVisible() Sometimes Confirms paint-related visibility, not initialization
toHaveCount(1) Limited Confirms mount, not interaction readiness
disabled=false Good Means the control is intended to accept input
data-ready="true" Good Explicit app readiness contract
Text content only Risky Text may render before handlers or data

If you can expose only one extra state for tests, expose readiness. A stable readiness contract reduces flaky waits and makes failures easier to interpret.

Who should skip this pattern

This approach is not a fit if your UI never lazily renders content. If every important element is mounted, painted, and hydrated before the page becomes usable, a simpler visibility-and-action test may be enough.

It is also a poor fit if your app has no reliable readiness signal and you are unwilling to add one. In that case, tests will drift toward arbitrary sleeps, which only hide timing bugs instead of diagnosing them.

Practical decision rule

Use DOM presence when you only need to know that the app created an element. Use visibility when you need to know the browser can paint it. Use a readiness signal when you need to know the component can be used. For content-visibility and offscreen UI, that third check is usually the one that prevents false confidence.

FAQ

Does content-visibility:auto hide elements from automation?

No. The element can still exist in the DOM, but the browser may skip rendering work until it is near the viewport. That is why tests need a readiness signal, not just a locator.

Should I always scroll before asserting on lazy content?

Only if the feature depends on visibility or intersection. If the app loads data independently of viewport position, scroll-based waits add noise.

Is toBeVisible() enough for offscreen UI?

Usually not. It can confirm that an element is visible, but it cannot prove that hydration, event listeners, or async data are finished.

What is the safest assertion for lazy hydration?

Assert an application-owned ready state, then interact, then verify the result of the interaction.

Why not use fixed sleeps?

Because they do not encode the actual ready condition. They make tests slower and still allow race conditions when initialization takes longer than the sleep.

Start with the MDN content-visibility reference and the Intersection Observer API. Those two documents explain the rendering and visibility mechanics behind the demo above.