Building a Button Part 2: Hover Interactions

By Devon Govett

This is the second post in our three part series on building a button component. In the first post, we covered how React Spectrum and React Aria implement adaptive press events across mouse, touch, keyboard, and screen readers. Today, we’ll cover hover interactions.

Hover interactions#


Hover interactions allow a user to receive some feedback when they move their pointer over an element, without pressing it. For example, the color of a button might change to give an affordance to the user that the element is clickable, or a tooltip may appear to give the user more information about what an element represents.

However, hover interactions are unique to mice. Most touch devices don’t allow the user to hover with their finger over an element without touching it. Keyboards support focusing elements, which is similar in some ways to hovering, but not quite the same. This presents some challenges when handling hover interactions on the web, given that web apps can run across so many different types of devices.

In the last post, we discussed how web browsers emulate mouse events for backward compatibility with older websites that were only designed with mice in mind. In addition to affecting how press events are dispatched, mouse event emulation also applies to hover events.

The :hover pseudo-class#


The first thing that may come to mind when you think about implementing a hover state for a component is the :hover CSS pseudo-class. It’s built right into the browser, requires no JavaScript to use, and seems like the perfect tool for the job. Unfortunately, it suffers from the same issues with emulated mouse events that we saw with the :active pseudo-class and mouse events in general.

On touch devices, :hover is emulated for backward compatibility with older apps that weren’t designed with touch in mind. Depending on the browser, :hover might never match, might match only while the user is touching an element, or may be sticky and act more like focus. On iOS for example, tapping once on an element shows the hover style, and tapping away from the element removes it.

This is not how you’d usually expect a button to behave, but browsers need to do this kind of emulation for apps that may only show or hide content on hover (e.g. navigation menus). If they did not, then perhaps this content would not be accessible at all to touch users. Unfortunately, there is no built-in way of opting out of this behavior, so we need to find another way to apply our hover styles.

Media queries#


The hover and any-hover media queries offer some hope. @media (hover: hover) matches when the user’s primary input device supports hover interactions, and @media (any-hover: hover) matches when any available input device supports hovering. This seems perfect – we can wrap our :hover pseudo classes in a media query, and only apply them when the device supports hover.

In fact, this is exactly what React Spectrum did for quite some time. But then we started testing on more types of devices, including Windows laptops with touch screens, and more recently on iPadOS 13.4, which supports trackpads and mice in addition to touch. These hybrid devices are incompatible with the hover media queries because the user can change interaction modes at any time. The hover media query would never match because the primary interaction mode is touch, and any-hover would always match because an available input device supports hover. (In reality it’s even more complicated because browsers and OS’s differ in which input device they consider primary). We want the hover state to apply only when the user is currently interacting with a mouse, but not when interacting with touch, so media queries won’t work.

JavaScript hover interactions#


Our only remaining option is to use JavaScript to apply our hover states instead of CSS. We’ll need to handle mouse events and apply our styles while the user is hovering over an element.

However, JavaScript mouse events are also subject to emulation on touch devices. onMouseOver and onMouseEnter are fired after onTouchEnd. In addition, onMouseExit and onMouseOut are not fired until the user taps on another element, just like with the :hover pseudo class. Because of this, we need to disambiguate between real mouse events and touch emulated mouse events.

As discussed in the previous post, pointer events are supposed to solve these issues by exposing a pointerType property that specifies what kind of device the user is interacting with. While onPointerEnter is fired even on devices that don’t support hover, we should be able to ignore these events if they have pointerType="touch" set.

Unfortunately, on iOS there is currently a bug where even pointer events are subject to mouse event emulation. iOS fires onPointerEnter twice – once with pointerType="touch" and again with pointerType="mouse". The mouse event is fired just after onPointerUp, and before onFocus. We could try setting a flag during the event with pointerType="touch" and ignore the following event with pointerType="mouse", but since this is a bug only on iOS, this would mean that we would ignore the next mouse event on other devices, which could be long in the future when the user switches interaction modes.

The solution is a bit tricky. We listen for the onPointerUp event globally on the document, and set a flag if pointerType="touch" to ignore the following onPointerEnter event with pointerType="mouse". After a short timeout (50ms), we reset this flag back to false. This means that we will ignore onPointerEnter events with pointerType="mouse" for 50ms following an onPointerUp event with pointerType="touch" – long enough to ignore the emulated mouse event on iOS, but short enough to not ignore real user events in the future.

This handler must be global to the document rather than local to the element being hovered due to another iOS quirk – focus events, and the prior onPointerEnter event with pointerType="mouse", are dispatched even when you didn’t touch the element directly, but somewhere nearby. iOS attempts to determine the user’s intent and focuses the nearest element to their tap within some threshold. In this case, the onPointerUp event with pointerType="touch" is not dispatched on the element since the user did not actually touch it. This means we would not be able to ignore the emulated mouse event, because our flag would never be set. Using a global event listener instead of a local one allows us to handle the onPointerUp event with pointerType="touch" and ignore the following onPointerEnter event with pointerType="mouse" even if the user touched nearby the element rather than directly on it.

I hear that these bugs may already be fixed in the iOS 14 betas, so hopefully we’ll be able to remove this code sometime in the future.

The useHover hook#


We’ve wrapped all of this behavior into the useHover hook in React Aria. It provides a simple way to determine if an element is hovered, and exposes a set of events that you can handle as well. onHoverStart is fired when the user hovers over an element with a mouse, and onHoverEnd is fired when the user moves their mouse off of the element. We take care of all of the browser inconsistencies discussed above, and also include fallbacks for touch and mouse events to support older devices without pointer events.

The Button component, and all other components in React Spectrum that support hover states, use the useHover hook to handle interactions, and apply a CSS class when they are hovered. This ensures that hover states are only applied when interacting with a mouse, which avoids unexpected behavior on touch devices.

Try a live example for yourself in our Button docs!

Conclusion#


As we’ve seen, cross-device interactions are difficult to handle across so many different types of devices. Even “simple” components like buttons are much more complicated than they seem at first. If you’re building your own button component, I’d recommend checking out the useButton and useHover hooks, which will help ensure that everything works as expected across a wide variety of devices.

In the next part of this series, we’ll cover how React Spectrum and React Aria handle focus behavior across devices and browsers.