PRACTICAL GUIDE / ElementNotInteractableException zero size Selenium

Your locator found the hidden clone, not the live control

Use rendered geometry and DOM context to identify Selenium's zero-size match, choose the active control, and avoid clicks that bypass user behavior.

By The Testing AcademyUpdated August 4, 202610 min read
All field guides
In this guide5 sections
  1. Why DOM presence is not enough
  2. Inspect every match, not only the first
  3. Tell a zero-size match from the nearby failures
  4. Fix the locator before adding a wait
  5. Do not bypass the user's interaction model

What you will learn

  • Why DOM presence is not enough
  • Inspect every match, not only the first
  • Tell a zero-size match from the nearby failures
  • Fix the locator before adding a wait

The locator succeeds, but the click fails with element not interactable: element has zero size. DevTools shows two Save buttons: a hidden template first and the live editor second. Selenium did exactly what the locator asked and returned the wrong one.

Why DOM presence is not enough

findElement() answers a narrow question: is there a matching node in the current search context? It does not promise that the node participates in layout or accepts the requested interaction. When several nodes match, the first matching element is returned, even if a later match is the control a user can see.

A node can exist without a usable rendered box for several reasons:

  • It or an ancestor has the HTML hidden attribute or display: none.
  • A collapsed component keeps its old DOM subtree mounted.
  • Desktop and mobile variants are both in the DOM, with CSS exposing only one.
  • A framework inserted the node before layout data or content made it visible.
  • The locator targeted a wrapper, hidden input, or template copy instead of the visible control.
  • The element sits in a closed panel whose dimensions have animated down to zero.

The WebDriver click algorithm needs an in-view center point. Selenium's Java API describes ElementNotInteractableException as the case where an element is present but not in a state that can be interacted with, including an undisplayed element or one whose center cannot be scrolled into the viewport. With no client rectangle or a rectangle with no usable width or height, the browser cannot produce a meaningful pointer target.

This is different from click interception. An intercepted element normally has a rendered box, but another painted element owns its click point. It is also different from NoSuchElementException, where the current DOM and search context contain no match, and from StaleElementReferenceException, where a previously returned node is no longer attached to the expected document.

WebElement.getRect() is a useful first check, but it is not the whole layout story. getClientRects() reports the CSS border boxes generated for an element, while getBoundingClientRect() provides their enclosing rectangle relative to the viewport. Computed styles and ancestor state explain why those values are empty or zero.

Inspect every match, not only the first

The following JUnit 5 test reproduces the hidden-clone problem. It prints layout evidence for both matches, proves that the broad locator selects the hidden button, then scopes the fixed locator to the active editor.

Java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.ElementNotInteractableException;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.support.ui.WebDriverWait;

class ZeroSizeElementTest {
    private final WebDriver driver = new ChromeDriver();

    @AfterEach
    void stopBrowser() {
        driver.quit();
    }

    @Test
    void selectsTheRenderedControlInTheActivePanel() {
        String html = """
            <section id="editor-template" hidden>
              <button class="save">Save</button>
            </section>
            <section id="active-editor" data-state="open">
              <button class="save"
                      onclick="document.querySelector('#status').textContent='saved'">
                Save
              </button>
              <p id="status">not saved</p>
            </section>
            """;
        String page = "data:text/html;base64," + Base64.getEncoder()
            .encodeToString(html.getBytes(StandardCharsets.UTF_8));
        driver.get(page);

        By broad = By.cssSelector("button.save");
        List<WebElement> matches = driver.findElements(broad);
        assertEquals(2, matches.size());

        for (int index = 0; index < matches.size(); index++) {
            System.out.printf("match[%d]=%s%n", index, layoutOf(matches.get(index)));
        }

        assertThrows(
            ElementNotInteractableException.class,
            () -> driver.findElement(broad).click()
        );

        By activeSave = By.cssSelector(
            "#active-editor[data-state='open'] button.save"
        );
        WebElement save = new WebDriverWait(driver, Duration.ofSeconds(2))
            .ignoring(StaleElementReferenceException.class)
            .until(current -> renderedElement(current, activeSave));

        save.click();
        assertEquals("saved", driver.findElement(By.id("status")).getText());
    }

    private WebElement renderedElement(WebDriver current, By locator) {
        List<WebElement> candidates = current.findElements(locator);
        if (candidates.size() != 1) {
            return null;
        }
        WebElement candidate = candidates.get(0);
        boolean hasUsableBox = (Boolean) ((JavascriptExecutor) current)
            .executeScript("""
                const el = arguments[0];
                const rect = el.getBoundingClientRect();
                return el.getClientRects().length > 0
                    && rect.width > 0 && rect.height > 0;
                """, candidate);
        return hasUsableBox && candidate.isDisplayed() && candidate.isEnabled()
            ? candidate : null;
    }

    @SuppressWarnings("unchecked")
    private Map<String, Object> layoutOf(WebElement element) {
        return (Map<String, Object>) ((JavascriptExecutor) driver)
            .executeScript("""
                const el = arguments[0];
                const rect = el.getBoundingClientRect();
                const style = getComputedStyle(el);
                const hiddenOwner = el.closest('[hidden]');
                const ariaHiddenOwner = el.closest('[aria-hidden="true"]');
                let cssHiddenOwner = null;
                for (let node = el; node; node = node.parentElement) {
                  const nodeStyle = getComputedStyle(node);
                  if (nodeStyle.display === 'none'
                      || nodeStyle.visibility === 'hidden'
                      || nodeStyle.visibility === 'collapse') {
                    cssHiddenOwner = node;
                    break;
                  }
                }
                return {
                  id: el.id || null,
                  connected: el.isConnected,
                  clientRectCount: el.getClientRects().length,
                  rect: {left: rect.left, top: rect.top,
                         width: rect.width, height: rect.height},
                  display: style.display,
                  visibility: style.visibility,
                  opacity: style.opacity,
                  hiddenAncestor: hiddenOwner ? hiddenOwner.id || hiddenOwner.tagName : null,
                  cssHiddenAncestor: cssHiddenOwner
                    ? cssHiddenOwner.id || cssHiddenOwner.tagName : null,
                  ariaHiddenAncestor: ariaHiddenOwner
                    ? ariaHiddenOwner.id || ariaHiddenOwner.tagName : null,
                  html: el.outerHTML.slice(0, 200)
                };
                """, element);
    }
}

The test uses a state-aware locator because the application exposes an active panel. The short geometry wait handles a real rendering transition and enforces exactly one candidate. Its cost is extra browser scripting on each poll. If the component has a reliable readiness attribute or accessible state, waiting on that contract is usually clearer than polling pixels.

Tell a zero-size match from the nearby failures

Run the failing method alone and keep the exception untrimmed:

Shell
mvn -Dtest=ZeroSizeElementTest#selectsTheRenderedControlInTheActivePanel \
  -DtrimStackTrace=false test

For a real failure, capture evidence before a retry or page refresh. Start with the match count. A count of zero points to timing, frame, shadow-root, or locator context. A count greater than one makes duplicate variants and hidden templates the leading suspects.

Next, compare each element's DOM context. IDs and a short outerHTML fragment often reveal mobile, template, closed, or inactive containers. Walk up to the nearest component root instead of dumping the entire page, which creates noisy artifacts and may expose user data.

Geometry separates several cases:

  • No client rectangles and zero dimensions usually indicate no generated CSS box, often because display: none or hidden applies in the ancestor chain.
  • Positive dimensions with visibility: hidden still describe layout space, but the element is not visible to the user.
  • Positive dimensions outside the viewport may be recoverable by WebDriver scrolling, unless a clipped container prevents a usable center from entering view.
  • Positive dimensions at the click point with a different topmost node indicate interception, not zero size.
  • A node that becomes disconnected while being inspected indicates a re-render and should be diagnosed as stale lifecycle behavior.

Take a screenshot at the same time, but do not expect an image to show a hidden clone. The DOM evidence explains what the screenshot cannot display. Conversely, a screenshot can reveal that the intended component never opened, so the hidden result is a downstream symptom of an earlier failed action.

Viewport and media queries matter. Reproduce the CI width, height, device scale, and browser. A desktop button can be hidden while a mobile menu action is active, or the reverse. Merely maximizing the window may make the failure disappear while abandoning the viewport the suite was meant to test.

Timing can change geometry without making the element stale. A framework may keep the same DOM node while a panel collapses, so the stored WebElement remains connected even though its rectangle becomes zero between lookup and click. Record geometry immediately before the action and compare it with the failure-time snapshot. Re-locating helps only when the locator expresses the active state; re-locating the same broad selector simply returns the same hidden node again.

Inspect the ancestor chain when the element's own computed display looks normal. display: none and visibility: hidden can come from a parent, and a closed dialog or inactive tab often marks the owning region rather than every descendant. aria-hidden="true" does not remove layout by itself, but it is a strong clue that the locator entered an inactive variant. A short path to the nearest hidden or state-bearing ancestor is more useful than hundreds of computed CSS properties.

Fix the locator before adding a wait

When two variants coexist, anchor the locator to the active component. Useful contracts include an open dialog, selected tab panel, active route region, or product-owned data-testid. The cost is tighter coupling to component state, but that coupling is honest: the test needs the Save button belonging to a specific editor, not any button with that text.

If only one element exists and it gains geometry after asynchronous rendering, wait for the application state that makes it ready. A positive rectangle can be a supporting condition, not the sole product assertion. An element can have width and height while still disabled, covered, or semantically unavailable.

Reject broad fixes such as this:

Java
driver.findElements(By.cssSelector("button.save")).stream()
    .filter(WebElement::isDisplayed)
    .findFirst()
    .orElseThrow()
    .click();

It may unblock one failure, but it silently accepts two displayed Save buttons and makes DOM order part of the test. Filtering is excellent diagnostic code. Production test code should state which component owns the control and, when duplicates would be a defect, assert uniqueness.

The product may need the fix. A control that remains zero-size in a supported viewport is not automation timing. The same is true when the visible label has no usable interactive target or a collapsed panel still receives focus. Escalate with the viewport, DOM state, rectangle, and screenshot instead of teaching the test to manipulate hidden internals.

Make uniqueness part of the contract when the page should expose one active control. A wait that returns only when exactly one state-scoped match has a usable box catches both absence and accidental duplication. The stricter condition can reveal product regressions that a first-visible filter would ignore, at the cost of failing during intentional designs that show the same action in two active locations.

Responsive products sometimes do intend two usable controls, such as matching actions in a header and sticky footer. In that case, identify the one belonging to the journey by region or accessible name rather than enforcing global uniqueness. The test should encode the product decision instead of assuming every duplicate is a defect.

Do not bypass the user's interaction model

JavaScript click() can invoke an event listener on an element that has no rendered box. That proves only that a handler exists. It does not prove a keyboard, mouse, or touch user can reach the control, so it is the wrong repair for an end-to-end interaction test.

Do not remove hidden, change CSS, or assign dimensions from the test. Those mutations create a page no customer sees. They also erase the evidence that would identify a responsive-layout or component-state defect.

Some elements are intentionally not clicked. Upload tests normally send a file path to the file input according to Selenium's upload flow, even when the application presents a styled label or button. Select elements should be operated through the supported selection interaction, not by clicking a hidden option. Custom controls should be driven through their visible, accessible surface.

Avoid a geometry wait when the missing box is permanent by design. No timeout turns a hidden template into the active editor. It only delays the same diagnosis and makes the suite slower.

Finally, do not treat a larger browser window as a universal solution. Change the viewport only when the test's declared device profile was wrong. If the suite covers the smaller layout, locate and use the control that layout actually exposes. A useful fix preserves the user's path and makes the locator's ownership unmistakable.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official w3.org reference

    w3.org

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official developer.mozilla.org reference

    developer.mozilla.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why did Selenium find an element and then say it has zero size?

Finding checks the DOM, while clicking requires a rendered, interactable target. The locator may have returned a hidden template, collapsed panel, duplicate mobile control, or element whose layout has not completed.

How can I confirm that Selenium matched a hidden duplicate?

Call findElements() with the same locator and record each match's ID, ancestors, client-rectangle count, dimensions, and computed display and visibility. Two matches with only one rendered inside the active component point to a locator problem.

Does presenceOfElementLocated mean an element can be clicked?

No. Presence only establishes that a matching node exists in the DOM. It says nothing about a rendered box, visibility, enabled state, obstruction, or whether the match belongs to the active UI.

Should I filter findElements() by isDisplayed() and click the first result?

That can help during diagnosis, but it is a weak long-term contract. A locator scoped to the open dialog or active panel is clearer and fails when the page accidentally renders two active controls.

Can JavaScript click solve an element has zero size error?

Programmatic activation may fire a handler on a control no user can reach. Fix the locator or wait for the intended layout state; if the visible UI never gains a usable box, report the product defect.