How to Test `content-visibility`, Lazy Rendering, and Offscreen UI Without Missing Hidden State Bugs
By Markus Gasser · September 23, 2026
Build a reproducible demo app for testing content-visibility, lazy rendering, and offscreen UI. Learn which browser automation assertions fail when elements are not painted, mounted, or fully initialized.
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:
- The card is not yet in the DOM.
- The card is in the DOM but not ready to interact with.
- 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:autosubtree, - 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:
- Confirm the node exists.
- Scroll it into view if the feature depends on being visible.
- Wait for a component-specific ready signal.
- Assert enablement or accessibility state.
- Trigger the user action.
- 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.
What primary docs should I read next?
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.