Save Expand icon

Ron Valstar
front-end developer

To determine touch-, mouse- or keyboard input and use it

How users interact with an application or website depends very much on their physical state. Are you sitting down in front a screen, in a warm room? Or are you standing outside doing a quick lookup with your thumb on your mobile? This should be the starting point of all user experience.

Unfortunately there is no definitive way to determine this physical state.
In fact, the way most applications detect a mobile device is misleading; they only look at the viewport width. Which is why a lot of websites will show you the mobile version if you shrink your browser window.
Browsers do not make it easy, but we can do a little better than just using the viewport width.

The way a user controls a device should determine the feedback it produces. Unfortunately this is often neglected. How a device is controlled shows in the form of events: touch, mouse or keyboard. We can use these with the viewport width to determine the physical state. Unfortunately this is not as straightforward as it sounds.

The DOM in Gecko, Blink and Webkit has no easy way to determine device capabilities or physical user input state.

Gecko, Blink and Webkit are the engines running Firefox, Chrome and Safari respectively. All other browsers use one of these engines (mostly Blink).

For example

On mobile you'll often see a touch input triggering the :hover state. The hover-state is really meant for mouse interaction. In some cases this causes a flicker of movement or change, making things look sloppy.

Other times you'll see the default :focus state on buttons and links deliberately disabled without an alternative. The undesired side effect is that tabbing through a form sometimes has no indication where the current focus is at.

No W3C standard

The difficulty is that browsers have no standard to determine input environment. Devices may support multiple types of input. A user may even switch from one to the other while browsing.

What's more, the state of a component is often programmed to be determined by the width of the viewport, not by feature detection and the width of the component. For a short while we had the media query device-width, but it got deprecated.

We do have the CSS pointer media query but this is a static mechanism.
There are solutions thay rely on it, using matchMedia('(pointer:fine)'). The trouble is that this doesn't tell you anything real; matchMedia tells you what the user-agent is capable of. Is does not have to apply to the current device. A laptop with a touch screen will have pointer:coarse even when using a mouse.

A JavaScript solution

Luckily we can fix this with a small set of JavaScript methods.

Most issues may be solved by some strategically placed CSS classNames.

So for instance placing html.user-input--mouse is enough to use in a CSS preprocessor with a parent-selector. Like in this mobile-first approach:

.button:hover {
  outline: none;
  .user-input--mouse & {
    outline: lightskyblue solid 0.125rem;
  }
}

The above shows the hover effect only when interacting with a mouse.

Some implementations might require a bit more logic. Apart from exposing the state with classNames we can use getters and event dispatchers (or observables) to communicate the physical interaction state.

Proper feature detection

There are a lot of examples online that use window.innerWidth or navigator.userAgent to determine a mobile environment. Which is a prejudiced way of going about it, not to mention the convoluted regex that is required (and outdated as soon as used).

The only way to be sure the user is navigating by touch, mouse, or keyboard is to use event listeners for mousemove, touchstart and keyup.
This means you'll only know for sure once the events fire. Which is why it pays to also store this state in sessionStorage to persist after (re)load.

The module below adds classNames to the documentElement and exposes callback methods for when the input state changes. A bit further down is an implementation example.

/**
 * Module to check for user input mouse, touch and keyboard
 * Sets the input-state as a className onto the documentElement.
 * Stores the input-state in localStorage.
 * Has an API in form of getters and event dispatchers.
 */

const {documentElement} = document||globalThis.document
const {classList} = documentElement

const addEventListener = documentElement.addEventListener.bind(documentElement)
const removeEventListener = documentElement.removeEventListener.bind(documentElement)

const block = 'user-input'
const className = {
  mouse: `${block}--mouse`,
  touch: `${block}--touch`,
  keyboard: `${block}--keyboard`
}
const event = {
  mousemove: 'mousemove',
  touchstart: 'touchstart',
  keyup: 'keyup',
  click: 'click'
}

const call = fn => fn()

const navigationKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Escape', 'Enter', 'Tab']

const storageName = 'userInput'
const sessionStorage = window.sessionStorage
const getItem = sessionStorage.getItem.bind(sessionStorage)
const setItem = sessionStorage.setItem.bind(sessionStorage, storageName)

const userInputDefault = {mouse: null, touch: null}
const state = JSON.parse(getItem(storageName)||'null')||userInputDefault

const mouseListeners = []
const touchListeners = []
const keyboardListeners = []

const mouseMoves = new Array(10).fill(0).map((v, i) => i * 1E9)

setDocumentElementClasses()

!state.mouse && addEventListener(event.mousemove, onMouseMove, true)
!state.touch && addEventListener(event.touchstart, onTouchStart, true)

addEventListener(event.keyup, onKeyUp, true)
addEventListener(event.click, onClick, true)

/**
 * Returns whether the device has mouse input
 * @returns {boolean}
 */
export function isUsingMouse() {
  return state.mouse
}

/**
 * Returns whether the device has touch input
 * @returns {boolean}
 */
export function isUsingTouch() {
  return state.touch
}

/**
 * Check if keyboard is used over mouse
 * @returns {boolean}
 */
export function isUsingKeyboard() {
  return state.keyboard
}

/**
 * Callback when mouse input is detected
 * @param {Function} fn
 */
export function whenMouse(fn) {
  fn && !state.mouse && mouseListeners.push(fn) || fn()
}

/**
 * Callback when touch input is detected
 * @param {Function} fn
 */
export function whenTouch(fn) {
  fn && !state.touch && touchListeners.push(fn) || fn()
}

/**
 * Callback when touch input is detected
 * @param {Function} fn
 */
export function whenKeyboard(fn) {
  fn && keyboardListeners.push(fn) || fn()
}

/**
 * MouseMove event listener (because touch devices can fire mouse events too)
 * Is removed when delta T falls below 50 milliseconds
 */
function onMouseMove() {
  mouseMoves.unshift(Date.now())
  mouseMoves.pop()
  const dist = mouseMoves
      .map((val, i, a) => Math.abs(val - a[i + 1]) || 0)
      .reduce((a, b) => a + b, 0) / (mouseMoves.length - 1)
  if (dist < 50) {
    removeEventListener(event.mousemove, onMouseMove, true)
    state.mouse = true
    store()
    mouseListeners.forEach(call)
  }
}

/**
 * TouchStart event listener
 * Removed when dispatched
 */
function onTouchStart() {
  removeEventListener(event.touchstart, onTouchStart, true)
  state.touch = true
  store()
  touchListeners.forEach(call)
}

/**
 * Add keyboard className for keyboard interaction
 * @param {KeyboardEvent} e
 */
function onKeyUp(e) {
  if (!isUsingKeyboard() && navigationKeys.includes(e.key)){
    state.keyboard = true
    store()
    keyboardListeners.forEach(call)
  }
}

/**
 * Remove focuseable className for mouse interaction
 */
function onClick() {
  if (isUsingKeyboard()) {
    state.keyboard = false
    store()
  }
}

/**
 * Session storage for user input mouse and touch
 */
function store() {
  setItem(JSON.stringify(state))
  setDocumentElementClasses()
}

/**
 * Set classes to the body for css usage
 */
function setDocumentElementClasses() {
  classList.toggle(className.mouse, state.mouse)
  classList.toggle(className.touch, state.touch)
  classList.toggle(className.keyboard, state.keyboard)
}

Things to note

The above script can be seen at work in this fiddle or in the example below.

Note that keyboard interaction will not automatically toggle the keyboard state. A lot of people will navigate a form using a mouse, type something, and navigate to the next field using the mouse. In this module the keyboard-state is only set when the keyboard is used to navigate (ie by pressing TAB or arrows).

Some assumptions are made that can easily be adjusted if needed.

For one the storage used is sessionStorage. It is smart to always default to sessionStorage to circumvent the mandatory cookie notification. Should you decide to use localStorage make sure to clear it after testing.

The other assumption is that you either use touch or mouse. There are indeed devices that are capable of both touch and mouse, and fewer users that actually switch between the two. Should you want both you'll have to remove the conditional before addEventListener, remove removeEventListener and add a toggle between the two.

Example implementation

A login screen with indicators below to show what state it is in, and also light up when the callbacks are fired:

I hope the module and example will help you understand how to detect the physical user input to create a more consistent user experience. Be aware that physical user input is not static, one may go from touch to keyboard to mouse on the same device.