Portaled overlays fail for boring reasons, not mysterious ones. The tooltip is rendered outside the clipped container, the anchor moves after hover, the animation is still running when the assertion fires, or the overlay closes because the pointer crosses a 1-pixel gap you did not account for. If you are trying to test portaled tooltips in browser automation, the goal is not to prove that a <div> exists. The goal is to verify the user-visible behavior that matters, position, collision handling, hover persistence, and dismissal, without tying the test to transient layout details.

A useful mental model: a tooltip, hover card, or floating panel has two parts, the trigger and the overlay. In the DOM, those parts may live in different subtrees because of a portal, but the user experiences them as one interaction. That distinction is what makes these tests tricky, and it is also what makes them testable if you assert the right things.

What makes portaled overlays flaky

The flake usually comes from one of four sources:

  1. Timing. Hover opens after a delay, transitions are still active, or the framework batches state updates.
  2. Positioning. The overlay is placed by a positioning engine such as Floating UI and may shift after collision detection, viewport resize, or scroll.
  3. Clipping. The trigger sits inside overflow: hidden, overflow: auto, or an element with transform or stacking context side effects, while the overlay is rendered elsewhere in the DOM.
  4. Dismissal behavior. The panel may remain open only while the pointer stays within a safe area, and a test that moves the mouse too aggressively can close it early.

The mistake is usually to assert the implementation detail first, for example, “the tooltip is inside this container.” For portal-based overlays, that is often the wrong invariant. A better invariant is, “the tooltip is visible, placed relative to the trigger, and behaves correctly when space is constrained.”

If you need a standards refresher on the underlying mechanics, React portals render children into a DOM node outside the parent hierarchy, while still preserving React tree relationships. See the React portal docs. For clipping and overflow behavior, the browser follows CSS layout rules, not test-framework assumptions. The distinction matters because a portal bypasses DOM ancestry, but it does not bypass rendering, hit-testing, or scroll clipping in the browser.

The assertions that survive layout shifts

Use assertions that map to user-observable behavior.

Prefer these

  • The overlay is visible after the trigger interaction.
  • The overlay is attached to the correct trigger, usually through accessible name, role, or aria-describedby/aria-controls links.
  • The overlay is positioned on the expected side of the trigger, if your design system guarantees side selection.
  • The overlay remains visible while pointer stays within the trigger and overlay safe area.
  • The overlay dismisses on Escape, blur, outside click, or scroll, depending on the component contract.

Avoid these as primary checks

  • Exact pixel coordinates.
  • Exact DOM nesting under the trigger.
  • Assertions that depend on a single animation frame.
  • Comparing screenshots before the overlay has settled.

Pixel assertions still have a place, but they should validate broad placement and collision handling, not exact math that can change with font metrics, zoom, subpixel rounding, or platform differences.

A stable Playwright pattern

The most reliable pattern is: locate the trigger by role, hover or click it, wait for the overlay to be visible and settled, then assert its semantics and approximate placement.

import { test, expect } from '@playwright/test';
test('tooltip opens and stays visible over a clipped card', async ({ page }) => {
  await page.goto('/overlay-demo');

  const trigger = page.getByRole('button', { name: 'Pricing details' });
  await trigger.hover();

  const tooltip = page.getByRole('tooltip', { name: /pricing details/i });
  await expect(tooltip).toBeVisible();
  await expect(tooltip).toHaveAttribute('data-side', /top|bottom|left|right/);
});

This works best when the component exposes stable accessibility metadata. The ARIA tooltip pattern is useful here, because it gives you a predictable role and an expected relationship to the trigger. If your component is actually a hover card or popover, use the relevant role and keyboard behavior instead of forcing everything into tooltip.

Wait for the overlay to settle

A lot of flakiness comes from asserting while a transition is still running. If the component uses CSS transitions, wait for a stable condition that reflects the visible state, not an arbitrary timeout.

await expect(tooltip).toBeVisible();
await expect(tooltip).toHaveCSS('opacity', '1');

If the overlay animates with transform or opacity, the CSS check can be enough to avoid racing the open animation. Keep it narrow. Do not wait for every CSS property unless the animation itself is the subject of the test.

How to verify position without overfitting to pixels

Floating UI testing is where most teams over-assert. The point is not to reproduce the library’s collision algorithm in your test, but to prove that the overlay ended up in a valid place.

A practical approach is to assert the side and a coarse geometric relationship.

const triggerBox = await trigger.boundingBox();
const tooltipBox = await tooltip.boundingBox();

expect(triggerBox).not.toBeNull(); expect(tooltipBox).not.toBeNull();

if (triggerBox && tooltipBox) { expect(tooltipBox.y + tooltipBox.height).toBeLessThanOrEqual(triggerBox.y + 8); }

That assertion says, in effect, “this tooltip is above the trigger, within a small tolerance.” If your component can flip to bottom when space is tight, parameterize the expected side and tolerate a small overlap margin caused by shadows or arrow geometry.

Better still, expose a test-friendly attribute such as data-side="top" or data-placement="bottom-start". Many positioning libraries already compute this value. Read it in the test, then pair it with a visible-state assertion. That keeps the test readable and avoids brittle geometry math unless you explicitly need it.

Testing inside clipping containers

Clipping containers change the failure mode. A tooltip that would be visible in a normal document flow may be partially hidden, fully hidden, or shifted by collision handling when the trigger sits near the edge of a scrollable region.

The scenario to reproduce

Create a demo page with a bounded scrolling container, a trigger near the bottom edge, and enough surrounding content to force real overflow.

<div class="scrollbox">
  <div style="height: 520px; padding: 24px;">
    <button aria-describedby="tip">Hover me</button>
    <div id="tip" role="tooltip">Clipped container tooltip</div>
  </div>
</div>

For the test, scroll the container so the trigger sits near the viewport edge, then open the overlay and verify it either flips or remains fully visible.

await page.locator('.scrollbox').evaluate((el) => {
  el.scrollTop = el.scrollHeight;
});
await trigger.hover();
await expect(tooltip).toBeVisible();
await expect(tooltip).toHaveAttribute('data-side', 'top');

That assertion is more robust than checking for a fixed DOM relationship. It validates the collision-handling outcome that users care about.

Hover persistence, pointer travel, and the 1-pixel problem

Hover cards often fail because the overlay closes when the pointer leaves the trigger before it reaches the panel. If the design uses a hover bridge or safe polygon, you need to verify that the overlay stays open during a realistic pointer transition.

The failure mode is not theoretical. A tiny gap between trigger and panel, plus an immediate close-on-mouseleave handler, creates a test that passes only when the cursor path is unrealistically perfect.

What to test:

  • Moving the pointer from trigger to overlay keeps the panel open.
  • Moving outside both trigger and overlay closes it.
  • Keyboard focus behaves independently from pointer hover, if the component supports keyboard access.

A clean Playwright version looks like this:

await trigger.hover();
await expect(tooltip).toBeVisible();
await tooltip.hover();
await expect(tooltip).toBeVisible();
await page.mouse.move(0, 0);
await expect(tooltip).toBeHidden();

If that last step flakes because the tooltip closes with a delay, express the delay in the component contract and wait for the expected hidden state rather than hard-coding a sleep.

Dismissal behavior needs separate coverage

Do not bundle open, position, and dismissal into a single happy-path test. Each behavior can fail independently.

Test these separately:

  • Escape closes the overlay.
  • Outside click closes it.
  • Focus loss closes it, if that is the intended behavior.
  • Scroll closes or repositions it, depending on product requirements.
await trigger.hover();
await expect(tooltip).toBeVisible();
await page.keyboard.press('Escape');
await expect(tooltip).toBeHidden();

If your design system keeps the overlay open on scroll and updates its position, assert that behavior explicitly. If it closes on scroll, assert that instead. The important part is to encode the contract, not a guess.

A compact decision table for overlay assertions

Behavior Stable assertion Avoid
Open state toBeVisible(), role-based locator waitForTimeout()
Placement data-side, coarse bounding-box check exact pixel equality
Collision handling expected side after constrained layout reproducing the library’s internal math
Hover persistence pointer moves into overlay without closing it a single hover path with no safe-area check
Dismissal Escape, outside click, blur, or scroll based on contract one monolithic end-to-end path

When a screenshot check is worth adding

A visual regression check is useful when the overlay has an arrow, shadow, or design-specific spacing that matters to the product. It is not a replacement for behavior checks, because a screenshot can pass while the tooltip is attached to the wrong trigger or closes too early.

Use it when the visual contract is part of the component API, especially for design-system overlays. Keep the viewport, device scale, and font loading controlled, or you will move the flake from interaction timing into rendering variance.

If you use visual testing, keep the screenshot region tight, around the overlay and trigger, rather than the whole page. That reduces unrelated diffs and makes failures easier to review.

Not the best fit if you need one universal assertion style

This approach assumes your overlay exposes accessible semantics or test hooks. If the component is a black box with no stable role, no state attribute, and no accessibility wiring, you can still test it, but the tests will lean harder on geometry and screenshots.

That is usually a signal to improve the component contract, not the test.

If the product must support multiple rendering engines, browser automation clouds, and aggressive CSS effects, you may also want a smaller set of contract tests at the component level, plus a few representative browser tests for the portal and clipping edge cases. That gives you coverage without turning every overlay into a fragile UI puzzle.

A practical checklist before you write the test

  • Confirm whether the overlay is a tooltip, hover card, or popover, and use the right role and keyboard behavior.
  • Expose a stable signal for placement, such as data-side or data-placement.
  • Reproduce clipping in a dedicated fixture page with constrained height and overflow.
  • Separate open, placement, hover persistence, and dismissal into distinct assertions.
  • Wait for visible state, not arbitrary time.
  • Keep geometry assertions coarse and user-facing.
  • Add a screenshot only if visual fidelity is part of the contract.

Bottom line

For portal-based overlays, the winning strategy is to test the user contract, not the DOM path. In browser automation, that means role-based locators, visible-state checks, coarse position assertions, and explicit dismissal coverage. If you write the test so that it only passes when the overlay is visible, correctly placed, and stable under clipping and hover movement, you will remove most of the flakiness without masking real regressions.

FAQ

Should I assert the tooltip is rendered inside the trigger container?

No. Portals intentionally render outside the parent DOM subtree. Assert visibility, accessibility relationships, and placement instead.

What is the most stable locator for a tooltip?

Use role and accessible name when possible, for example getByRole('tooltip') or the trigger’s accessible name. Add a test-specific attribute only if the component does not expose a stable semantic hook.

How do I test collision handling near the edge of a clipped container?

Put the trigger near the constrained edge, open the overlay, then assert the expected data-side or a coarse bounding-box relationship. Avoid exact pixels.

Why does hover testing flake so often?

Because the pointer path, animation timing, and close-on-mouseleave logic interact. A tiny gap between the trigger and overlay can close the panel before the assertion runs.

Should I use screenshots for floating UI tests?

Use them as a supplement, not the primary assertion. Screenshots are good for arrows, spacing, and styling, but they do not prove correct behavior by themselves.