Pixelated mouse and pointer cursors scattered in various orientations

Handle mouse and touch events uniformly in JavaScript

Don't let your beautifully crafted UI fall flat because mobile users can't interact. Learn how to handle mouse and touch events uniformly in vanilla JavaScript to create a consistent user experience across desktop and mobile devices.

On the surface, it would seem that a browser would treat a touch even just like a mouse event, but if you’ve worked in cross-platform development using web technologies you likely will have discovered that this is not the case.

On a mobile device, the touch event is fired immediately, however, it does not fire a click event until after 300ms, so a tap on a button will have a 300ms delay if it is bound to the click event. This pause is to allow the click event to be cancelled and handled as some other event like a long press, drag, or zoom. In some contexts this is fine behavior and most users don’t notice it for most applications. However, there are instances where a 300ms pause is very noticeable and will potentially break your application, or perhaps you want to be able to press and hold a button for alternative behavior.

Fear not, the solution is straight forward and it allows for much more fine-grained control over the user experience regardless of the device type (touch or mouse driven). By the end of this article you should be able to create a custom binding for any web environment. If you would like a look at a complete binding library for reference, check out my Vue.js click directive tool vue-click.

Event Binding

I could go into great detail about specific event types, but the excellent MDN page has a good list of all the events that can be used in a browser. For this article, we are focusing on the Touch Event and Mouse Event.

These events (TouchEvent and MouseEvent) are issued from specific DOM elements and climbs up the tree of elements until it either reaches the top, or it reaches an element which has a binding telling it to stop climbing. The first step in capturing one of these events is to identify the element we would like to capture the event from and bind it.

// Grab the element with the id of `#my-button`
const el: HTMLELement = document.querySelector('#my-button')

// Bind the `click` event on the element
el.addEventListener('click', (e: MouseEvent) => {
  console.log('clicked!')
})

This code will bind the click event and will result in the console showing clicked! when the element is clicked, however, it will not prevent every other element above it in the stack from also receiving the same click event. To stop this we need to set options to capture the event.

// Grab the element with the id of `#my-button`
const el: HTMLELement = document.querySelector('#my-button')

// Set a callback function to handle the event
const eventCallback = (e: MouseEvent) => {
  console.log('clicked!')
}

// Bind the `click` event on the element
el.addEventListener('click', eventCallback, { capture: true })

Granular Event Handlers

Great, now we have a click event bound, but that doesn’t help use make a uniform user experience. For that we need to bind the specific start and stop events instead of the click events.

// Grab the element with the id of `#my-button`
const el: HTMLELement = document.querySelector('#my-button')

// Set a callback function to handle the event
const eventCallback = (e: MouseEvent | TouchEvent) => {
  console.log(e.type)
}

// Bind the mouse events on the element
el.addEventListener('mousedown', eventCallback, { capture: true })
el.addEventListener('mouseup', eventCallback, { capture: true })

Now when we click, we get an immediate mousedown in the console, followed by a mouseup once the mouse is released. No matter how long the mouse is held down, it will still fire the mouseup event once it’s released. Now we add the touch events.

// Grab the element with the id of `#my-button`
const el: HTMLELement = document.querySelector('#my-button')

// Set a callback function to handle the event
const eventCallback = (e: MouseEvent | TouchEvent) => {
  console.log(e.type)
}

// Bind the mouse and touch events on the element
el.addEventListener('touchstart', eventCallback, { capture: true })
el.addEventListener('touchend', eventCallback, { capture: true })
el.addEventListener('mousedown', eventCallback, { capture: true })
el.addEventListener('mouseup', eventCallback, { capture: true })

Now when we click or touch and receive granular events callbacks for each phase of the event.

Cleaning up

We’ve done a good job capturing the events, but what happens if we don’t need to capture the events anymore? We can remove the listeners we added.

el.removeEventListener('touchstart', eventCallback, eventOptions)
el.removeEventListener('touchend', eventCallback, eventOptions)
el.removeEventListener('mousedown', eventCallback, eventOptions)
el.removeEventListener('mouseup', eventCallback, eventOptions)

This will remove the listener and allow the event to climb the DOM tree again.

Uniform Binding Function

Let’s put it all together and create a uniform binding function. This function will take in the element, and 2 callbacks, one for the start of the event, and one for the end of the event. The function will return an unbind function which will remove the event listeners when called.

const bindUserInteractionEvents = (
  el: HTMLElement,
  onStart: () => void,
  onEnd: () => void
) => {
  // Set a callback function to handle the event
  const eventCallback = (event: MouseEvent | TouchEvent) => {
    // If the event is a start of a touch or mouse event call `onStart`
    if (event.type === 'mousedown' || event.type === 'touchstart') {
      onStart()
    } else {
      // Otherwise call `onEnd`
      onEnd()
    }
  }

  // Bind the mouse and touch events on the element
  el.addEventListener('touchstart', eventCallback, eventOptions)
  el.addEventListener('touchend', eventCallback, eventOptions)
  el.addEventListener('mousedown', eventCallback, eventOptions)
  el.addEventListener('mouseup', eventCallback, eventOptions)

  return () => {
    // Unbind the mouse and touch events on the element
    el.removeEventListener('touchstart', eventCallback, eventOptions)
    el.removeEventListener('touchend', eventCallback, eventOptions)
    el.removeEventListener('mousedown', eventCallback, eventOptions)
    el.removeEventListener('mouseup', eventCallback, eventOptions)
  }
}

With this function, we can bind the events to any element we want, and we can also unbind them later.

// Grab the element with the id of `#my-button`
const el: HTMLELement = document.querySelector('#my-button')

// Bind the mouse and touch events on the element
const unbindEl = bindUserInteractionEvents(
  el,
  () => {
    console.log('clicked!')
  },
  () => {
    console.log('released!')
    unbindEl()
  }
)

This function will log clicked! and released! one time each before it is unbound.

Taking it further

This is the basic foundation of a uniform click handler. The next step is to define event pairs. For example, a click is composed of a onDown and then a onUp event. If you have no other event handling then this definition is all you need, but what if you also want to handle a longClick function. Well, then a click might be a onDown and a onUp event that both happen within 300ms (remember that fun number from earlier?). In a future article I will dive into the specifics of capturing event pairs to define a broader set of user interactions.