A test that says “the button exists” is not the same as a test that proves a user could click it. Sticky headers, lazy layout shifts, cookie banners, and animated nav bars can leave an element visible in the DOM but covered in the viewport. The result is a familiar failure pattern: the test scrolls, the click is intercepted, and the failure appears random even though the page behavior is deterministic.

The practical fix is not “add more waits.” It is to separate three checks that are often conflated: the element is present, the element is scrolled into a usable viewport position, and the pointer hit-test lands on the element rather than on an overlay. That distinction matters whether you write tests in Playwright or Selenium.

If a click assertion fails intermittently, assume the page is moving first, not the framework.

What this problem actually is

There are three common failure modes behind a click that looks flaky:

  1. Sticky or fixed UI covers the target, for example a header, cookie banner, or floating promo bar.
  2. Layout shifts after scrolling, often from images, fonts, accordions, or lazy-loaded content changing the target’s final position.
  3. The element is technically in view, but not hit-testable, meaning the browser would route the click to another element.

That last case is the one that produces messages like “element click intercepted” in Selenium or actionability failures in Playwright. The important point is that the automation framework is not “wrong.” It is telling you the browser would not send the click to the intended node.

For reference, Playwright’s locator actions are actionability-checked, and Selenium documents click behavior through WebDriver element interaction rules. Those checks are useful, but they do not remove the need to design stable scroll and occlusion assertions around your page.

The rule of thumb

If the test cares about whether a user could click the control, assert on clickability, not just presence.

That usually means:

  • scroll to a known-safe position,
  • verify the element’s center point is not covered,
  • then click,
  • then assert the user-visible outcome.

Do not rely on scrollIntoView() alone. It may align the element under a sticky header or at the bottom edge where a floating footer covers the target.

A compact decision table

Situation Safer tactic Why it helps
Sticky header covers top-of-page targets Scroll with an offset, not just into view Keeps the target below the fixed header
Long SPA with layout shifts Wait for layout to settle, then inspect geometry Reduces clicks during movement
Overlay or banner may intercept clicks Hit-test the element’s center before clicking Proves the pointer would land on the intended node
Element is off-screen inside a scroll container Scroll the container, not only the window The page may be visible while the element is not
Test only needs behavior, not pointer interaction Use a lower-level assertion or direct event path Avoids brittle “real click” requirements when unnecessary

Playwright: make clickability explicit

Playwright gives you good primitives for this because locators, bounding boxes, and evaluate hooks are easy to combine. The key is to avoid using locator.click() as your only proof. Use it only after you have confirmed the viewport position and hit-test surface.

Scroll with a controlled offset

const target = page.getByRole('button', { name: 'Get started' });
await target.scrollIntoViewIfNeeded();
await page.evaluate(() => window.scrollBy(0, -120));

That offset is a blunt instrument, but it is often enough for a fixed 64px or 80px header. If your layout has variable header heights, compute the offset from the header element instead of hardcoding it.

Verify the element is not covered

test('cta is clickable below the sticky header', async ({ page }) => {
  const target = page.getByRole('button', { name: 'Get started' });
  await target.scrollIntoViewIfNeeded();

  const box = await target.boundingBox();
  expect(box).not.toBeNull();

  const x = Math.round(box!.x + box!.width / 2);
  const y = Math.round(box!.y + box!.height / 2);

  const coveredBy = await page.evaluate(({ x, y }) => {
    const el = document.elementFromPoint(x, y);
    return el?.closest('button, a, [role="button"]')?.textContent ?? el?.tagName;
  }, { x, y });

  expect(coveredBy).toContain('Get started');
  await target.click();
});

This pattern checks the center point that a real pointer click would use. If a sticky banner or overlay is on top, elementFromPoint() exposes it.

When scrollIntoViewIfNeeded() is not enough

Playwright’s auto-waiting is good at waiting for stability, but it cannot decide where your application should place the element relative to a fixed header. If the page uses a long sticky nav, a centered scroll position can still be wrong. In that case, prefer a page-specific helper that scrolls to a known offset and asserts the target’s bounding box sits below the header.

A framework can wait for visibility, but it cannot infer your page’s safe click zone.

Selenium: verify the browser would accept the click

Selenium tests often become flaky when teams rely on element.click() and assume the browser will do the right thing. It might, until a sticky element covers the target or the page shifts during scroll.

Use a real scroll check before clicking

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

button = driver.find_element(By.XPATH, “//button[normalize-space()=’Get started’]”) driver.execute_script(“arguments[0].scrollIntoView({block: ‘center’});”, button) WebDriverWait(driver, 10).until(EC.element_to_be_clickable((By.XPATH, “//button[normalize-space()=’Get started’]”))) button.click()

block: 'center' is better than the default alignment when a sticky header is present, but it is still not enough on its own. element_to_be_clickable checks visibility and enabled state, not whether another layer is on top of the element.

Add a hit-test helper when occlusion matters

python rect = button.rect x = rect[‘x’] + rect[‘width’] / 2 y = rect[‘y’] + rect[‘height’] / 2

covered = driver.execute_script( “return document.elementFromPoint(arguments[0], arguments[1])?.textContent”, x, y ) assert ‘Get started’ in (covered or ‘’)

This is the missing assertion in many Selenium suites. It turns a vague click failure into a precise diagnosis: the intended target is not at the top of the stacking order at the point where the click would land.

How to diagnose scroll jank before blaming the click

Scroll jank is not only a performance problem. It also creates test instability because the target’s position changes after you scroll to it.

A practical diagnostic sequence is:

  1. Measure whether the element moves after initial scroll.
  2. Check whether fonts, images, or accordions are still loading.
  3. Re-read the element’s bounding box immediately before the click.
  4. Confirm the center point still belongs to the intended element.

In Playwright, that often means reading boundingBox() twice, once after scroll and once just before click. In Selenium, it means re-checking rect or getBoundingClientRect() through JavaScript instead of assuming the earlier geometry is still valid.

If the layout keeps shifting, reduce the test surface. For example, test the sticky header’s behavior in a dedicated layout test, then test CTA behavior at a stable route or viewport size. Do not make one test prove layout, animation, lazy loading, and click behavior all at once.

Practical page patterns that reduce flakiness

These are implementation choices, not test-framework tricks:

  • Add stable IDs or accessible names to interactive controls.
  • Keep sticky headers from changing height during scroll.
  • Avoid late-loading assets above primary call-to-action buttons.
  • Reserve space for images and banners so the layout does not jump.
  • If a cookie or promo banner can cover the viewport, give tests a deterministic way to dismiss it.

If your application uses a persistent banner, consider making test environments start in a known state where the banner is already dismissed or minimized. That is usually better than clicking through the banner in every test.

A better assertion than “the click worked”

For this class of issue, the most useful test often has two assertions:

  1. The intended element is at a safe viewport position and not covered.
  2. The click produces the expected user-visible change.

For example, a CTA test can assert that the target is centered below the header, then verify the resulting navigation, modal, or state change. That combination catches both occlusion and logic regressions.

A brittle test says:

  • “I clicked a button.”

A stronger test says:

  • “The button was actually reachable at the time of interaction, and the user-facing result happened.”

Where Playwright has an edge, and where Selenium still fits

For sticky-header and occlusion work, Playwright is often simpler because locators, auto-waiting, and browser-context APIs make it easy to write small geometry checks close to the action. Its built-in actionability model also surfaces useful failures when the element is not receiving pointer events.

Selenium still fits well when a team already has a mature WebDriver suite, cross-language bindings matter, or the test stack already standardizes on WebDriver infrastructure. It is perfectly capable of validating clickability, but you usually need to be more explicit about scroll placement and hit-testing.

The tradeoff is not “one can test this and the other cannot.” The real difference is how much helper code you need to make the intent readable and stable.

Not the best fit if

This technique is a poor fit when the test is not actually about a user click. If you only need to verify data rendering, a network request, or a state transition, a pointer click assertion adds noise.

It is also a poor fit if the page intentionally overlays the target, for example a modal or dismissible sheet that must be closed first. In that case, test the overlay dismissal path separately and keep the target interaction in a clean state.

A simple checklist you can reuse

Before you file a flaky click as a framework bug, check these items:

  • Is the target covered by a sticky header, banner, or modal?
  • Did the page shift after the scroll?
  • Did scrollIntoView() place the element under a fixed region?
  • Does elementFromPoint() at the click coordinates return the intended element?
  • Is the element inside a scrollable container rather than the main document?
  • Are you asserting the visible result, not just the click call?

Bottom line

To test sticky headers and element occlusion reliably, treat clickability as a viewport problem, not a DOM problem. Scroll to a known safe position, hit-test the point where the click will land, and assert the user-visible outcome after the interaction. That approach removes most of the guesswork from flaky click failures and makes the remaining failures much easier to debug.

FAQ

Why does element_to_be_clickable still fail on covered elements?

Because it checks visibility and enabled state, not whether another element is covering the click point.

Is scrollIntoView() enough for sticky headers?

Usually not. It can align the element under a fixed header or footer, so you need an offset or a page-specific scroll helper.

What causes “element click intercepted” errors?

Another element, often a sticky nav, banner, or overlay, is receiving the pointer event instead of the target.

Should I use JavaScript clicks to bypass occlusion?

Only if the test is not meant to validate real user interaction. A JS click can hide a genuine usability problem.

How do I test a button inside a scrollable container?

Scroll the container itself, then verify the element’s center point with a hit-test before clicking.

What is the most stable assertion for this problem?

A combined assertion: the target is in a safe viewport position, the point is not covered, and the UI changes after the click.