UI Component Testing Guide

How to Test Tooltip Hover Delay with Playwright: Timing, Positioning, and Touch Fallback

A scenario-by-scenario walkthrough of testing tooltips with Playwright. Hover intent delays, Floating UI and Popper positioning, arrow placement, dismiss on scroll, touch device fallback, and the timing traps that make tooltip tests the flakiest in your suite.

300ms

Most tooltip libraries default to a 200 to 400 millisecond hover intent delay before showing content. This intentional delay is the single largest source of flaky tooltip tests in Playwright suites.

Floating UI / Radix UI documentation

0msTypical hover intent delay
0Tooltip scenarios covered
0Positioning placements
0%Fewer lines with Assrt

Tooltip Lifecycle: Hover to Dismiss

User CursorTrigger ElementFloating UITooltip DOMScroll ContainermouseenterStart delay timer (300ms)Compute position + create portalTooltip visible with arrowUser scrolls pageScroll event firesDismiss tooltipmouseleave

1. Why Testing Tooltip Hover Delay Is Harder Than It Looks

Tooltips appear simple on the surface: hover over an element, a small text box appears, move away and it disappears. But the implementation details create a minefield for automated tests. The first obstacle is the hover intent delay. Libraries like Floating UI, Radix UI, Tippy.js, and Material UI all implement an intentional delay (typically 200 to 400 milliseconds) between the mouseenterevent and the tooltip becoming visible. This delay prevents tooltips from flickering when users move their cursor across a toolbar quickly. Playwright's hover() method dispatches a mouseenter event instantly, but if your test asserts visibility immediately after, the tooltip has not appeared yet.

The second obstacle is dynamic positioning. Modern tooltip libraries use Floating UI (the successor to Popper.js) to compute the tooltip's position relative to its trigger element. The library measures available viewport space and may flip the tooltip from top to bottom, shift it horizontally to stay within bounds, or adjust the arrow offset. These position calculations happen asynchronously after the tooltip mounts in the DOM, which means a tooltip can be present in the DOM but not yet in its final position. Asserting position too early catches a transient state.

Third, tooltips are typically rendered through portals. React portals, Vue Teleport, and Angular CDK Overlay all mount the tooltip outside the component tree, usually as a direct child of document.body. This means the tooltip DOM is not a descendant of the trigger element. Playwright locator chains that start from the trigger (like trigger.locator('[role="tooltip"]') ) will never find the tooltip. You need to query from the page root.

Fourth, scroll containers dismiss tooltips. Most implementations listen for scroll events on ancestor elements and hide the tooltip when any ancestor scrolls. Fifth, touch devices have no hover event at all, so tooltip libraries implement fallback patterns like long press or tap to show, which require entirely different test strategies. Sixth, accessibility requirements demand that tooltips are associated with their triggers via aria-describedby and have role="tooltip", but many implementations get this wrong or skip it entirely, leaving your tests without reliable selectors.

Tooltip Hover Intent Flow

🌐

mouseenter

Cursor enters trigger

⚙️

Delay Timer

200-400ms wait

⚙️

Position Calc

Floating UI computes

🌐

Portal Mount

Tooltip appended to body

Arrow Align

Arrow points to trigger

Visible

Opacity transition completes

Tooltip Dismiss Paths

🌐

mouseleave

Cursor exits trigger

↪️

Scroll Event

Ancestor container scrolls

🔒

Escape Key

User presses Escape

↪️

Focus Loss

Focus moves elsewhere

Tooltip Hidden

Portal removed from DOM

A thorough tooltip test suite covers all of these dimensions. The sections below walk through each scenario with runnable Playwright TypeScript you can paste directly into your project.

2. Setting Up a Reliable Tooltip Test Environment

Before writing tooltip scenarios, configure your Playwright project to handle the timing and viewport requirements that tooltip testing demands. Tooltips are sensitive to viewport size (a tooltip that fits on a 1280px screen may flip or overflow on 800px), animation durations, and pointer type.

Tooltip Test Environment Checklist

  • Set a consistent viewport size (1280x720 minimum) to avoid position flips
  • Configure actionTimeout to at least 5000ms to accommodate hover delays
  • Create a test page with tooltip triggers at known positions (top, bottom, edges)
  • Include both Floating UI and Radix UI tooltip implementations if your app uses both
  • Add a CSS transition override helper for tests that need instant positioning
  • Configure a touch device project for mobile tooltip fallback tests
  • Disable system-level pointer acceleration in CI to get consistent hover coordinates

Playwright Configuration

playwright.config.ts

Tooltip Test Fixture

Create a shared fixture that provides helper methods for hovering with delay awareness. The core problem with page.hover() is that it fires the mouseenter event but returns immediately. Your fixture should hover and then wait for the tooltip to become visible, handling the delay automatically.

test/fixtures/tooltip.ts
Installing Tooltip Test Dependencies

3. Scenario: Basic Hover Shows Tooltip After Delay

The most fundamental tooltip test verifies that hovering over a trigger element causes the tooltip to appear after the configured delay, and that moving the cursor away causes it to disappear. This sounds trivial, but the hover intent delay is where most teams introduce their first flaky test. The common mistake is calling page.hover() and immediately asserting that the tooltip is visible. Since Playwright dispatches the hover event synchronously but the tooltip appears after a timer, the assertion races against the delay and fails intermittently.

1

Basic Hover Shows Tooltip After Delay

Straightforward

Goal

Hover over a button with a tooltip, confirm the tooltip text appears within the expected delay window, then move the cursor away and confirm the tooltip disappears.

Preconditions

  • A page with a button that has a tooltip configured with a 300ms delay
  • The tooltip uses role="tooltip" for accessibility
  • Desktop viewport with mouse pointer

Playwright Implementation

tooltip-basic.spec.ts

What to Assert Beyond the UI

  • The tooltip has role="tooltip" in the DOM
  • The trigger element has aria-describedby pointing to the tooltip's id
  • The tooltip is not present in the DOM before hover (portal not mounted)
  • The tooltip disappears completely (removed from DOM, not just hidden with opacity)

Assrt Equivalent

# scenarios/tooltip-basic-hover.assrt
describe: Basic tooltip appears on hover with delay

given:
  - I am on /components/tooltip-demo
  - the "Save changes" button has a tooltip configured

steps:
  - hover over the "Save changes" button
  - wait for the tooltip to appear

expect:
  - a tooltip with text "Save your current progress" is visible
  - the trigger has aria-describedby linking to the tooltip
  - moving the cursor away hides the tooltip

Basic Hover: Playwright vs Assrt

import { test, expect } from '@playwright/test';

test('tooltip appears after hover delay', async ({ page }) => {
  await page.goto('/components/tooltip-demo');

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

  const tooltip = page.getByRole('tooltip', {
    name: 'Save your current progress'
  });
  await expect(tooltip).not.toBeVisible();
  await expect(tooltip).toBeVisible({ timeout: 2_000 });
  await expect(tooltip).toHaveText('Save your current progress');

  await page.mouse.move(0, 0);
  await expect(tooltip).not.toBeVisible({ timeout: 2_000 });
});
54% fewer lines

4. Scenario: Floating UI Positioning and Flip

Floating UI computes the optimal placement for a tooltip based on available viewport space. A tooltip configured with placement: "top"will render above its trigger when there is room, but when the trigger is near the top edge of the viewport, Floating UI's flip middleware repositions the tooltip to the bottom. This flip behavior is invisible to users (it just works) but it creates a testing challenge: the same trigger can produce different tooltip positions depending on viewport size and scroll position.

Testing positioning requires you to control the viewport precisely, scroll to specific offsets, and then verify the tooltip's computed position relative to the trigger. Playwright gives you everything you need: page.setViewportSize(), page.evaluate() for reading bounding rectangles, and boundingBox() on locators. The key insight is that you must wait for Floating UI to finish its position calculation before reading coordinates. Floating UI runs asynchronously via requestAnimationFrame, so you need a small settle period after the tooltip becomes visible.

2

Floating UI Positioning and Flip

Complex

Goal

Verify that a tooltip configured with placement: "top" renders above the trigger when space is available, and flips to bottom when the trigger is near the viewport top edge.

Playwright Implementation

tooltip-positioning.spec.ts

What to Assert Beyond the UI

  • The tooltip's data-placement or data-side attribute reflects the actual rendered side (not the configured default)
  • The tooltip stays fully within the visible viewport after flip and shift
  • No content is clipped or overflowing when the tooltip is near an edge

Try Assrt for free

Open-source AI testing framework. No signup required.

Get Started

5. Scenario: Arrow Placement Follows Trigger

Many tooltip implementations include a small arrow (also called a caret or pointer) that visually connects the tooltip to its trigger element. Floating UI's arrowmiddleware computes the arrow's offset dynamically so it always points at the center of the trigger, even when the tooltip body has been shifted to fit within the viewport. When the tooltip flips from top to bottom, the arrow must rotate 180 degrees. When the tooltip shifts left, the arrow must adjust its horizontal offset. Testing this requires reading the arrow element's computed style and comparing it to the trigger's position.

3

Arrow Placement Follows Trigger

Moderate

Goal

Verify that the tooltip arrow points at the horizontal center of the trigger element, even when the tooltip body is shifted.

Playwright Implementation

tooltip-arrow.spec.ts

6. Scenario: Dismiss on Scroll

Tooltips should dismiss when the user scrolls the page or any ancestor scroll container. This is a UX convention that prevents orphaned tooltips from floating in the wrong position after their trigger has scrolled out of view. Floating UI provides a hide middleware and a autoUpdate listener that tracks scroll events and recalculates position or hides the tooltip. Some implementations dismiss immediately on scroll; others recalculate and only dismiss if the trigger is no longer visible.

Testing scroll dismiss in Playwright requires dispatching actual scroll events. Using page.mouse.wheel() or page.evaluate(() => window.scrollBy(0, 100)) both produce scroll events that tooltip libraries listen for. The distinction matters: mouse.wheel() dispatches a wheel event that triggers native scrolling, while evaluate calls scrollBy directly. Some tooltip implementations only listen for scroll events (not wheel), so using evaluate with scrollBy is more reliable for testing.

4

Dismiss Tooltip on Scroll

Moderate

Goal

Show a tooltip via hover, then scroll the page and verify the tooltip is dismissed. Also test scrolling within a nested scroll container.

Playwright Implementation

tooltip-scroll-dismiss.spec.ts

What to Assert Beyond the UI

  • The tooltip portal node is removed from the DOM after dismiss (not just hidden with CSS)
  • The trigger's aria-describedby attribute is removed when the tooltip is dismissed
  • No stale tooltip state remains that could cause the next hover to skip the delay

Scroll Dismiss: Playwright vs Assrt

import { test, expect } from '@playwright/test';

test('tooltip dismisses on scroll', async ({ page }) => {
  await page.goto('/components/tooltip-demo');

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

  const tooltip = page.getByRole('tooltip');
  await expect(tooltip).toBeVisible({ timeout: 2_000 });

  await page.evaluate(() => window.scrollBy(0, 100));

  await expect(tooltip).not.toBeVisible({ timeout: 2_000 });
});
45% fewer lines

7. Scenario: Touch Device Fallback (Long Press)

Touch devices have no hover event. When a user taps a tooltip trigger on a phone or tablet, the browser fires a click event, not a mouseenter. This means the hover intent delay pattern does not apply. Most tooltip libraries handle this by switching to a tap-to-show or long-press pattern on touch devices. Radix UI, for example, shows the tooltip on long press and dismisses on the next tap anywhere. Material UI provides a enterTouchDelay prop that controls how long a touch must be held before the tooltip appears.

Testing touch behavior in Playwright requires configuring a project with hasTouch: true and isMobile: true. Then use page.touchscreen.tap() for simple taps and page.dispatchEvent() with touchstart and touchend for long press simulation.

5

Touch Device Tooltip via Long Press

Complex

Goal

On a mobile device emulation, long press a tooltip trigger, verify the tooltip appears, then tap elsewhere to dismiss it.

Playwright Implementation

tooltip-touch.spec.ts

What to Assert Beyond the UI

  • The tooltip is accessible to screen readers on touch (the aria-describedby relationship is established after long press, not after tap)
  • A simple tap on the trigger performs the button's primary action, not the tooltip (separation of click and tooltip concerns)
  • The tooltip does not interfere with native scroll gestures on mobile

Touch Fallback: Playwright vs Assrt

import { test, expect, devices } from '@playwright/test';

test.use({ ...devices['iPhone 14'], hasTouch: true });

test('long press shows tooltip', async ({ page }) => {
  await page.goto('/components/tooltip-demo');
  const trigger = page.getByRole('button', { name: 'Save changes' });
  const box = await trigger.boundingBox();
  const cx = box!.x + box!.width / 2;
  const cy = box!.y + box!.height / 2;

  await trigger.dispatchEvent('touchstart', {
    touches: [{ clientX: cx, clientY: cy }],
  });
  await page.waitForTimeout(700);
  await trigger.dispatchEvent('touchend', {});

  const tooltip = page.getByRole('tooltip');
  await expect(tooltip).toBeVisible({ timeout: 2_000 });

  await page.touchscreen.tap(10, 10);
  await expect(tooltip).not.toBeVisible({ timeout: 2_000 });
});
56% fewer lines

8. Scenario: Rapid Hover Between Multiple Triggers

A toolbar with multiple buttons, each with its own tooltip, creates a common race condition. When the user moves the cursor from one trigger to an adjacent one quickly, the expected behavior is: the first tooltip should dismiss, and the second should appear. Some libraries implement a "group delay" optimization where, if you have already seen one tooltip in a group, subsequent tooltips in the same group appear instantly (skipping the normal hover intent delay). Radix UI calls this the skipDelayDuration prop on its TooltipProvider.

Testing this scenario is especially tricky because you need precise cursor movement timing. If your test moves from trigger A to trigger B too slowly, the first tooltip dismisses and the normal delay kicks in for the second. If it moves too quickly, the mouseleave and mouseenter events may fire in an order the library does not expect. The key is to use page.mouse.move() with explicit coordinates rather than locator.hover(), which lets you control the exact path and speed of the cursor.

6

Rapid Hover Between Adjacent Triggers

Complex

Goal

Hover trigger A until its tooltip appears, then move directly to trigger B and verify that tooltip A disappears and tooltip B appears (with reduced or zero delay if skip-delay is configured).

Playwright Implementation

tooltip-rapid-hover.spec.ts

9. Common Pitfalls That Break Tooltip Test Suites

Asserting Visibility Immediately After Hover

The most common tooltip test failure is calling trigger.hover() and then immediately asserting expect(tooltip).toBeVisible() without a timeout. Playwright's default toBeVisible() retries for 5 seconds by default, which is usually sufficient. But if you chain it with { timeout: 0 } or use a custom assertion that does not retry, the test will fail on every tooltip that has a hover intent delay. Always use the retrying assertion pattern: await expect(tooltip).toBeVisible() with a reasonable timeout.

Looking for Tooltip Inside the Trigger Subtree

Tooltips rendered via portals are not DOM children of the trigger. If you write trigger.locator('[role="tooltip"]') , the locator will never match. Always query from the page root: page.getByRole('tooltip'). If you need to associate a tooltip with its trigger, use the aria-describedby attribute on the trigger, which contains the id of the tooltip element.

Hardcoding Position Expectations

A test that asserts "tooltip is above the trigger" will break when the CI runner has a different viewport size that causes Floating UI to flip the tooltip. Either lock the viewport size in your Playwright config or test relative positioning ("tooltip does not overlap trigger" and "tooltip is within viewport bounds") rather than absolute direction.

Using waitForTimeout Instead of Assertions

Writing await page.waitForTimeout(500) before checking the tooltip is a code smell. If the tooltip library changes its delay from 300ms to 400ms, your 500ms wait still passes. But if it changes to 600ms, your test breaks. Use await expect(tooltip).toBeVisible() with Playwright's built-in auto-retry, which polls at 100ms intervals and succeeds as soon as the tooltip appears, making your test both faster and more resilient.

Forgetting CSS Transition Timing

Many tooltip libraries animate the tooltip with a CSS opacity or transform transition. During the transition, the tooltip is in the DOM and has display: block, but its opacity may be 0. Playwright's toBeVisible() considers an element with opacity: 0 as not visible, which is correct behavior. But if you use a custom visibility check (like querying for the element's existence), you may get a false positive during the enter transition.

Tooltip Testing Anti-Patterns

  • Asserting visibility with timeout: 0 right after hover()
  • Querying tooltip as a child of the trigger element (portals break this)
  • Hardcoding top/bottom position without controlling viewport size
  • Using waitForTimeout(500) instead of retrying assertions
  • Checking DOM existence instead of visibility during CSS transitions
  • Not testing dismiss paths (scroll, Escape, focus loss)
  • Ignoring touch device behavior entirely
  • Assuming only one tooltip implementation across the whole app
Tooltip Test Suite Run

10. Writing Tooltip Tests in Plain English with Assrt

Every scenario above requires careful attention to timing, portal traversal, and viewport management. The hover delay alone introduces three lines of boilerplate per test (hover, skip the immediate assertion, wait with timeout). Multiply that by ten tooltip scenarios and a dozen trigger locations, and you have a substantial test file that breaks the moment your tooltip library updates its delay from 300ms to 400ms or moves its portal container from body to a dedicated div#floating-root.

Assrt lets you describe the tooltip behavior in plain English, generates the Playwright TypeScript code with correct timing patterns, and regenerates selectors and delay handling automatically when the underlying library changes. The scroll dismiss scenario from Section 6 shows this well: in raw Playwright, you need to know whether to use mouse.wheel() or evaluate(scrollBy). In Assrt, you say "scroll the page" and let the framework pick the right approach.

scenarios/tooltip-full-suite.assrt

Assrt compiles each scenario block into the Playwright TypeScript you saw in the sections above, committed to your repo as real tests you can read, run, and debug. When your tooltip library updates its delay timing, renames its portal container, or changes how it handles touch events, Assrt detects the failure, analyzes the new DOM, and opens a pull request with updated selectors and timing values. Your scenario files remain unchanged.

Start with the basic hover delay scenario. Once it is green in your CI, add the positioning flip test, then scroll dismiss, then touch fallback, then rapid hover. In a single afternoon you can have complete tooltip coverage that catches the subtle regressions most teams miss: the tooltip that appears 200ms too late, the arrow that points 12 pixels off center, the long press that conflicts with the context menu on Android.

Related Guides

Ready to automate your testing?

Assrt discovers test scenarios, writes Playwright tests from plain English, and self-heals when your UI changes.

$npm install @assrt/sdk