← all posts · javascript

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.

01 May 2026 · 3 min read · Stephen Masters javascriptmobile

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:

javascript
1234567891011
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:

  1. First tap: focusclick (stickyUrl set ✓)
  2. Finger lifts: blur (stickyUrl cleared ✗)
  3. Second tap: focusclick (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:

javascript
12345
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:

javascript
1234
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).

SM
Stephen Masters

Software developer and architect. I build systems for places that move energy, commodities, and money around. I keep a bike-packing journal at velostevie.com.