How One CSS display Rule Broke Hit Testing
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 */
}wrapperstays asblock(the default), so during normalization the inner node is returned correctly.position: absolutewithleft/topplaces it where you want.- The parent
containergetsposition: relativeto act as the anchor forabsolute.
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
caretRangeFromPointandelementFromPointinternally perform normalization based on the block/inline structure. - When
displaychanges, it's not just the layout that shifts — the very basis of normalization changes. - Instead of
inline-block, usingposition: absolutefor placement lets you keepblockwhile producing the same layout.