Touch events and focus on mobile — the two-tap trap
Why the 'first tap previews, second tap acts' pattern is broken on touch devices, and what to do instead.
The pattern that seems reasonable
You have a UI element — a map marker, a card, a thumbnail — where hovering reveals a preview and clicking performs an action. On desktop this works cleanly: mouseenter shows the preview, click performs the action.
On touch devices there’s no hover, so you adapt: first tap shows the preview, second tap performs the action. The implementation usually looks something like this:
var stickyUrl = null;
btnEl.addEventListener('focus', function () { showPreview(); });
btnEl.addEventListener('blur', function () { hidePreview(); stickyUrl = null; });
btnEl.addEventListener('click', function () {
if (stickyUrl !== null) {
openLightbox(stickyUrl); // second tap
} else {
stickyUrl = m.url; // first tap — show preview, remember URL
}
});Reasonable enough. First tap sets stickyUrl and shows the preview. Second tap finds stickyUrl set and opens the lightbox.
It doesn’t work.
Why it breaks
On mobile, the browser fires a blur event after every tap. The moment the user lifts their finger, the element loses focus. Your blur handler runs, clears stickyUrl, and resets everything — before the second tap can register.
The sequence of events for two taps on mobile is actually:
- First tap:
focus→click(stickyUrl set ✓) - Finger lifts:
blur(stickyUrl cleared ✗) - Second tap:
focus→click(stickyUrl is null, shows preview again)
The lightbox never opens. The user taps forever.
This is not a bug you can easily reproduce on a desktop browser’s mobile emulator — device emulation doesn’t faithfully reproduce mobile focus behaviour. You need a real device or browser stack to catch it.
The fix
The two-tap pattern assumes focus can persist between taps on touch. It can’t. The fix is to stop trying.
The hover preview is inherently a pointer feature: on touch there is no hover, so the preview adds friction rather than value. Showing a preview on first tap forces the user to tap twice to do what they came to do.
Remove the two-tap logic entirely. One tap, one action:
btnEl.addEventListener('mouseenter', function () { scheduleShow(m, btnEl); });
btnEl.addEventListener('mouseleave', function () { scheduleHide(); });
btnEl.addEventListener('focus', function () { scheduleShow(m, btnEl); });
btnEl.addEventListener('blur', function () { scheduleHide(); });
btnEl.addEventListener('click', function () { openLightbox(m.url); });mouseenter and mouseleave handle the hover preview on pointer devices — they never fire on touch. click opens the lightbox on all devices. The preview still works for desktop users; mobile users get a direct tap-to-action.
If you want to call hide() before opening the lightbox — to cleanly dismiss any visible preview — do it at the start of the action function:
function openLightbox(url) {
hide(); // dismiss preview before lightbox opens
// … open the lightbox …
}The broader rule
Don’t rely on focus persisting between separate user interactions on touch devices. Desktop users have a cursor that maintains hover/focus state continuously; touch users interact in discrete, stateless taps. Design for the touch model — one tap, one outcome — and layer hover enhancements on top for pointer devices.
The test for whether a pattern works on touch: if removing the hover/focus event listeners entirely would break the intended flow, the flow is designed for desktop and needs a touch alternative (or to be simplified).