In a fanned hand of cards, I pressed a card I wasn't allowed to play, and the game played a different card: the one lying under it. The click handler was fine. The card I pressed had pointer-events: none, and that property doesn't stop a press. It takes the element out of hit-testing, the step where the browser works out which element is under the pointer, so the press goes to the next element down, and in a fan the next element down is the neighbouring card.
This post has the minimal repro, a table of where the press lands in Chromium, Firefox and WebKit for five ways of switching an element off, and the fix.
The repro
<!doctype html>
<style>
.hand { position: relative; height: 140px; margin: 20px; }
.card { position: absolute; top: 0; width: 80px; height: 120px; }
.card:nth-child(1) { left: 0; }
.card:nth-child(2) { left: 40px; }
.card:nth-child(3) { left: 80px; }
.off { pointer-events: none; opacity: .5; }
</style>
<div class="hand">
<button class="card" id="a">A</button>
<button class="card off" id="b">B</button>
<button class="card" id="c">C</button>
</div>
<script>
document.querySelector('.hand').addEventListener('click', (e) => {
console.log('played', e.target.id);
});
</script>
Three cards, each covering the right half of the one before it. B is dimmed and has pointer-events: none. Click the strip of B that you can see, between A and C. The console says played a.
You can ask the browser what a press at that spot would hit:
const r = document.getElementById('b').getBoundingClientRect();
document.elementsFromPoint(r.left + 20, r.top + 60).map((el) => el.id || el.tagName);
// ["a", "DIV", "BODY", "HTML"]
B isn't in the list at all. That is what the spec asks for. CSS UI 4 says that with none, hit-testing "acts as if the boxes generated by the element were not there, effectively causing the element behind the pointer-events: none element to become the target of hit-testing-based events instead."
The part I had wrong was "the element behind". It isn't the page background. It is whatever hit-testable box comes next in paint order, and if that box is another live control, the press is now a press on that control.
Five ways to switch B off, in three engines
I disabled B five different ways and pressed its visible strip in Chromium 148, Firefox 150 and WebKit 26.4: once with Playwright's mouse, once with Playwright's emulated touchscreen, and once by tabbing to it from A and pressing Enter. A capturing listener on window logged the target of every pointer, touch, mouse and click event.
| B is switched off with |
pointerdown lands on |
click, mouse |
click, touch |
Tab from A, then Enter |
|---|---|---|---|---|
| nothing (control) | B | B | B | focuses B, clicks B |
pointer-events: none |
A | A | A | focuses B, clicks B |
inert |
A | A | A | skips B, focuses C |
disabled |
B | no click | no click; A in WebKit | skips B, focuses C |
aria-disabled="true" |
B | B | B | focuses B, clicks B |
The three engines agree everywhere except the one cell marked WebKit. Four things fall out of it.
pointer-events: none switches off the pointer, not the element. Every event in the sequence, from pointerdown to click, went to A, with the mouse and with touch. The keyboard is untouched: B still takes focus, and Enter clicks it. The spec says so too: "pointer-events: none does not inhibit sequential focus navigation." So a card switched off this way plays its neighbour when you click it and plays itself when you press Enter on it.
inert behaves the same for the pointer. inert is an HTML attribute that switches off an element and everything inside it, and the HTML spec defines it this way. The first rule in its list for an inert node is "Hit-testing must act as if the 'pointer-events' CSS property were set to 'none'." It also takes B out of the tab order, which is why Tab went from A straight to C. It is the right tool for a whole region the user can't reach, such as the page behind a modal, and the wrong one for a single item that sits on top of other live items.
disabled keeps the press on B, but not every event. Hit-testing stops at the disabled button. With the mouse, no engine dispatched a click anywhere, and with touch, Chromium and Firefox didn't either. But pointerdown and pointerup still fired on B in all three engines, so a handler on pointerdown gets no protection from disabled. And with emulated touch in WebKit, pointerdown and touchstart went to B while the mousedown, mouseup and click that follow a tap went to A. That happened when the element under B was another button; with a <div> under B, nothing received a click, even with a click listener on it. disabled also only exists on form controls, so a card built from a <div> can't use it.
aria-disabled changes nothing about the press. Every event landed on B, in every engine, from every input. That is the behaviour you want: the press reaches the element the user aimed at, and your code decides what it means.
The fix
Keep every item hit-testable and refuse the press in the handler. Against the repro, replace the .off rule, mark B with the attribute instead of the class, and check it in the handler:
.card[aria-disabled="true"] { opacity: .5; }
<button class="card" id="b" aria-disabled="true">B</button>
document.querySelector('.hand').addEventListener('click', (e) => {
const card = e.target.closest('.card');
if (!card) return;
if (card.getAttribute('aria-disabled') === 'true') {
console.log('refused', card.id);
return;
}
console.log('played', card.id);
});
A press on B's strip now logs refused b in all three engines, with the mouse, with touch, and with Tab and Enter. elementsFromPoint at the same spot returns ["b", "a", "DIV", "BODY", "HTML"]: B is back on top of the stack, with A under it where it belongs. The dimming and the refusal read the same attribute, so they can't disagree.
pointer-events: none is still the right tool when you want the press to go to whatever is underneath: a label floating over a canvas, or a drag preview that follows the pointer while you hit-test for the drop target under it. Use it for things that should be transparent to the pointer, never as a way to say "this control is off".
Two caveats. These are Playwright's synthesized mouse, touch and keyboard input, not a physical mouse or a finger on a phone, so test the devices you ship to. In particular, the WebKit touch result comes from Playwright's WebKit build with emulated touch; I haven't reproduced it on an iPhone, so treat it as a reason to check Safari, not as a fact about it.
How much of a fan it hits
In a fan, most of the part of a card you can see lies on top of the card before it. With pointer-events: none on the cards you can't play, a press on one of them goes to the card underneath, and on down the stack until it reaches a card that takes presses, or the table. It only plays the wrong card when an unplayable card lies on a playable one.
I measured how much of a hand that is in a game of Spades, where the hand is sorted by suit and, when you have the suit that was led, only that suit may be played. I dealt eight hands, four at 1280×900 with the mouse and four at 390×844 with emulated touch, and stopped at the first trick where I had to follow suit. For each card I couldn't play, I sampled its visible area on a 2 px grid with elementFromPoint, then switched back to the old rule, under which only playable cards take presses, and checked what each sampled point hit.
- In four hands, the playable suit was the last one in the fan. Nothing lay on it, and every stray press fell to the table.
- In the other four, the card lying on the end of the playable run had 50–89% of its visible area over a playable card. The 50% was the last card in the hand, whose whole face shows.
- At phone size the fan is tighter, and the card after that one still had 7% of its visible area over the same playable card: a press can fall through two cards.
In all four of those hands, a press on that card played the playable card under it with the old rule, and played nothing with the fix in place. The fixed hand is in this Spades game: when you have to follow suit, a press on a card you can't play does nothing.
Takeaways
-
pointer-events: nonedoesn't disable an element. A press goes to whatever hit-testable box is underneath, and the keyboard still reaches the element itself. -
inertdoes the same to the pointer, by definition, and also removes focus. Use it for whole regions, not single items in a stack. -
disabledstopsclickbut notpointerdownorpointerup, and exists only on form controls. - In any UI where items overlap, keep each item hit-testable and refuse the press in your handler.
document.elementsFromPoint(x, y)shows you what a press at a point would hit.
Top comments (0)