How One CSS display Rule Broke Hit Testing

#CSS#Browser#JavaScript

When you're doing web development, there are times when you need to know exactly which DOM node was clicked at the user's coordinate. It comes up in editors, custom drag-and-drop, coordinate-based interactions, and plenty of other situations.

Browsers support coordinate-based node lookup through APIs like caretRangeFromPoint and elementFromPoint. Most of the time they work fine, but when the DOM is nested several layers deep, a single display property on a parent element can completely change the result.

I clearly clicked on an inner node, yet the parent node was returned. The cause was one line of CSS.


How the browser's hit testing works

Let's take caretRangeFromPoint(x, y) as an example. Internally this API goes through two steps.

Step 1 — hitTest

It finds the closest node at the given (x, y) coordinate. This step usually behaves as expected.

Step 2 — Normalization

Based on the hitTest result, it finds an inline element within the block container that node belongs to, and decides the final result from there.

The key is this normalization step. The browser figures out "which block is this node inside of," and then picks an appropriate inline element from that block to return. And that's exactly where the problem showed up.


The problem — display changes the hit testing result

Consider the following nested structure.

<div class="container">
  <div class="wrapper">
    <span>Hello World</span>
  </div>
</div>

When wrapper is display: block

<div class="container">
  <div class="wrapper" style="display: block;">
    <span>Hello World</span>
  </div>
</div>
hitTest result: node inside <span>
Normalization: inline element inside wrapper(block) → returns <span>

The browser recognizes wrapper as the block container and properly returns the <span> inside it.

When wrapper is display: inline-block

<div class="container">
  <div class="wrapper" style="display: inline-block;">
    <span>Hello World</span>
  </div>
</div>
hitTest result: node inside <span>
Normalization: inline element inside container(block) → returns wrapper

Once wrapper becomes inline-block, the basis for normalization shifts. container becomes the block container, and when the browser looks for an inline element inside it, wrapper itself is at the inline level — so wrapper gets returned. It never reaches the inner <span>.

Just one line of CSS changed, and the API's return value flipped entirely.


The fix — keep the layout with position, restore block

So why was inline-block used in the first place? Most often, for positioning. People reach for inline-block to lay elements out side-by-side or to place them at specific spots.

The solution was to leave display alone and position the element with position instead.

/* Before — hit testing broken */
.wrapper {
  display: inline-block;
}
 
/* After — hit testing works */
.wrapper {
  /* display: block (default) */
  position: absolute;
  left: 0px;
  top: 0px;
}
 
.container {
  position: relative; /* anchor for absolute */
}
  • wrapper stays as block (the default), so during normalization the inner node is returned correctly.
  • position: absolute with left/top places it where you want.
  • The parent container gets position: relative to act as the anchor for absolute.

The layout stays the same, and hit testing works correctly again.


Wrap-up

A single CSS property can affect not just the layout but also the browser's internal behavior — even changing what an API returns.

  • Coordinate-based APIs like caretRangeFromPoint and elementFromPoint internally perform normalization based on the block/inline structure.
  • When display changes, it's not just the layout that shifts — the very basis of normalization changes.
  • Instead of inline-block, using position: absolute for placement lets you keep block while producing the same layout.