Sticky Hovers, Part 1: @media Queries

Let’s take a trip back in time to 1998. May 12th, 1998, to be exact. It was a simpler time — the Lewinsky scandal was dominating American politics, Michael Jordan and the Chicago Bulls were on their way to a second three-peat in basketball, and Billboard’s #1 song was about getting… “excited” when grinding with your girl on the dance floor:

1. “You’re makin’ it hard for me…” ಠ_ಠ #

May 12th was also the day that the CSS2 specification became a recommendation. In addition to adding support for features like media types, relative/absolute positioning and minimum/maximum widths and heights, CSS2 introduced the :hover pseudo-class to the others carried over from CSS1. Over the years, its definition hasn’t changed since its debut:

The :hover pseudo-class applies while the user designates an element (with some pointing device), but does not activate it. For example, a visual user agent could apply this pseudo-class when the cursor (mouse pointer) hovers over a box generated by the element. User agents not supporting interactive media do not have to support this pseudo-class. Some conforming user agents supporting interactive media may not be able to support this pseudo-class (e.g., a pen device).

~ CSS2 Specification, 5.11.3 The dynamic pseudo-classes: :hover, :active, and :focus

Now I don’t know if you’ve noticed, but the Internet landscape has changed dramatically over the past 19 years. :hover itself evolved beyond anchor tags to cover pretty much every tag you can mouseover. In 2017, most of us use mobile devices that support interactive media as mentioned by the spec, but by their nature cannot support a hover state. However, we developers still account for the :hover state in our CSS. So what does a mobile browser do when it encounters :hover declarations in CSS?

Good question!

The MDN entry for :hover includes a usage note for mobile browsers. TL;DR: It’s complicated:

Usage Note: on touch screens :hover is problematic or impossible. Depending on the browser, the :hover pseudo-class might never match, or match only for a short moment after touching an element, or may continue to match even after the user has stopped touching and until the user touches another element. As touchscreen devices are very common, it is important for web developers not to have content be accessible only when hovering over it, as this content is more cumbersome or impossible for users of such devices to access.

So in theory :hover on mobile can be activated:

  • Never
  • On touch for a short time
  • On touch until something else is touched
  • None of the above ¯\_(ツ)_/¯

Cool… what about in practice?

Do You Really Wanna Touch It?

Each browser has its own way of handling :hover. The MDN article also includes this note under Browser Compatibility:

As of Safari Mobile for iOS 7.1.2, tapping a clickable element causes the element to enter the :hover state, and the element will remain in the :hover state until a different element has entered the :hover state.

Microsoft, of course, has to differ from Apple by making theirs active on longpress, what they call “press and hold”:

Internet Explorer 11 on Windows 8.1 has built-in touch support for common UX scenarios requiring hover, like hover menus, via a press and hold user interaction pattern. [emphasis theirs]

And according to this Chromium bug report, Chrome for Android utilizes both the tap and the longpress for hover:

I believe we _do_ want to set :hover while the element is being touched, we just want to clear it on tap (and tap cancel) instead of letting it be sticky today.

We should probably still allow longpress to trigger sticky hover.

The tap method used by Safari and Chromium is known as sticky hover. Just as the velvet voices and smooth basslines of an aforementioned ‘90s R&B jam sticks in your head until you hear another song, the hover state “sticks” until some other hover state or click event is activated. However, unlike a ‘90s R&B jam, it can be the bane of a webmaster’s existence.

So how do we make hover not stick?

Enter @media

JavaScript Kit published an article last year detailing four ways to combat the sticky hover issue:

  1. Add a non-touch CSS class using JavaScript
  2. Add a can-touch CSS class using JavaScript
  3. Use CSS Media Queries Level 4 to control display based on device capabilities
  4. Dynamically add a can-touch CSS class using JavaScript

Number 3 caught my eye. We can supposedly use those ever-versatile @media queries — also introduced in the CSS2 spec back in ‘98, by the way — to control output based on whether or not hover capability is supported. To do this, we use the hover feature, currently a working draft, to make that determination:

The ‘hover’ media feature is used to query the user’s ability to hover over elements on the page. If a device has multiple input mechanisms, the ‘hover’ media feature must reflect the characteristics of the “primary” input mechanism, as determined by the user agent.

hover was introduced alongside a couple other feature detection abilities like touch precision. As mentioned in the draft spec, it matches the capabilities of the primary way of interacting with a site. Therefore, if your target audience uses devices with multiple ways of input or interaction, you will need to simply substitute any-hover for hover.

How to Do It

The two possible values for the hover (and thus any-hover) are hover and none , for matching devices that can and cannot hover respectively. There used to be a third option, on-demand, that corresponded to a longpress:

Indicates that the primary pointing system can hover, but it requires a significant action on the user’s part. For example, some devices can’t normally hover, but will activate hover on a “long press”.

While it is no longer a part of the spec, some browsers still use it for the time being (Chrome for Android, looking in your direction). Since we’re casting a wide net here, we should err on the side of backwards compatibility and include them in our CSS.

Therefore, we would target devices that support hover with this:

@media (hover: hover) {
    /** Declarations go here **/
}

and devices that don’t (or do partially) with this:

@media (hover: none), (hover: on-demand) {
    /** Declarations go here **/
}

This is all fine and good, but do modern browsers even support it?

Browser Support in 2017

According to Can I Use, most browsers currently support hover; the only ones who don’t are IE (Edge supports it), Firefox and Opera Mini. However, that’s only part of the story — we need to see how each of them supports it. To do this, I whipped up a simple CodePen that colorizes table cells based on how each of them interact with hover.

See the Pen @media (hover: *) Test by Jonathan Ledbetter (@jledbetterpdx) on CodePen.0

2. @media (hover: *) test #

Here are the results:

3. Mobile hover interactivity by browser #
Browser Version Matches
iOS 9.3+ @media (hover: none)
Android/Chrome (phones) 53+ @media (hover: on-demand)
Android/Chrome (tablets) 53+ @media (hover: hover)
Opera Mobile 42+ @media (hover: hover)
Opera Mini Not supported
Firefox Mobile Not supported, kinda
Windows Mobile 8.1 Not supported

Right away we see a couple issues. Android/Chrome is using the deprecated hover: on-demand feature, but only for phones — tablets report themselves as hover: hover, just like Opera Mobile does. Also, Firefox’s omission is puzzling, as there’s been a ticket open for almost 3 years requesting development. For some webmasters, lack of Firefox support is a dealbreaker. However, our target audience tends to use the default browsers on their iPhones and Android devices, so we can choose to put off full Firefox Mobile support for the time being and more or less ignore Opera altogether. Your mileage may vary.

Code in Action

While our CSS includes a bunch of hover effects, the one presenting the greatest accessibility challenge is the opacity fade on the new nav menu for screen widths above 864px. (This is more of a “mystery meat” menu situation than sticky hover, but the same principles apply.) Presently, the relevant code looks like this:

/** Default menu style for small screens - 3x3 grid **/
nav ul#pages {
    list-style-type: none;
    font-family: 'Amatic SC', cursive;
    display: flex;
    justify-content: space-between;
    flex-flow: row wrap;

}

nav ul#pages li {
    margin: 0;
    display: inline-block;
    width: 33.3%;
}

nav ul#pages li a {
    color: hsl(0, 0%, 90%);
    text-decoration: none;
    display: inline-flex;
    flex-flow: column nowrap;
    height: 100%;
    width: 100%;
    align-items: center;
    font-size: 1.45rem;
    letter-spacing: -0.05rem;
    padding: 1rem 0;
}

nav ul#pages li.donate a {
    background-color: hsl(120, 100%, 13%);
}

/** Lowers icon **/
nav ul#pages li a i {
    font-size: 2.125rem;
    -webkit-transition-duration: 0.2s;
    -moz-transition-duration: 0.2s;
    -ms-transition-duration: 0.2s;
    -o-transition-duration: 0.2s;
    transition-duration: 0.2s;
}

/** Raises icon **/
nav ul#pages li a:hover i {
    transform: translateY(-0.125rem);
    -webkit-transition-duration: 0.4s;
    -moz-transition-duration: 0.4s;
    -ms-transition-duration: 0.4s;
    -o-transition-duration: 0.4s;
    transition-duration: 0.4s;
}

/** Displays hovered menu item with no delay **/
nav ul#pages li a:hover span {
    transition-delay: 0s !important;
}

/** Screen sizes 39em and larger - adjusts layout to single row **/
@media only screen and (min-width: 39em) {
    nav ul#pages {
        flex-flow: row nowrap;
    }

    nav ul#pages li {
        width: 11.1%;
    }
}

/** Screen sizes 54em and larger - adjusts layout to left menu **/
@media only screen and (min-width: 54em)
{
    nav ul#pages {
        flex-flow: column nowrap;
        margin: 0.5rem 0;
    }

    nav ul#pages li {
        width: 100px;
    }

    nav ul#pages li a {
        padding: 0.5rem 0;
    }

    nav ul#pages li.donate a {
        background-color: transparent;
    }

    /** Changes donate button background to green **/
    nav ul#pages:hover li.donate a {
        background-color: hsl(120, 100%, 13%);
    }


    nav ul#pages li a i {
        font-size: 2.5rem;
    }

    /** Fades label out **/
    nav ul#pages li a span {
        opacity: 0;
        -webkit-transition-duration: 0.2s;
        -moz-transition-duration: 0.2s;
        -ms-transition-duration: 0.2s;
        -o-transition-duration: 0.2s;
        transition-duration: 0.2s;
    }

    /** Fades label in **/
    nav ul#pages:hover li a span {
        opacity: 1;
        -webkit-transition-duration: 0.4s;
        -moz-transition-duration: 0.4s;
        -ms-transition-duration: 0.4s;
        -o-transition-duration: 0.4s;
        transition-duration: 0.4s;
    }

    /** Delays second label fade by 40ms **/
    nav ul#pages:hover li:nth-child(2) a span {
        transition-delay: 40ms;
    }

    /** Delays third label by 80ms **/
    nav ul#pages:hover li:nth-child(3) a span {
        transition-delay: 80ms;
    }

    /** (repeat for 4-11, adding 40ms each time) **/

    nav ul#pages:hover li:nth-child(12) a span {
        transition-delay: 550ms;
    }

} 

There are two ways to apply the sticky hover fix to this code:

  1. Page elements have :hover applied by default, and sticky hover is removed via @media (hover: none) and (hover: on-demand)
  2. Page elements do not have :hover applied, and only apply it on supported devices using @media (hover: hover)

If we were to implement it the first way, we would support hover to all visitors by default, then roll back the hover state effects on mobile Android/Chrome and Safari. This would give us greater consistency across all devices, but would leave Firefox Mobile with sticky hover. The second way would give no browser a hover state initially, only adding functionality back on desktop browsers. This provides us with a clean stack to add hover declarations to, but since Firefox ignores @media (hover: hover) neither desktop nor mobile will have hover effects1.

In our case, because the hover effect is meant to add a little life to the site, I decided to retain the current hover declarations and focus on disabling label fades and icon movement for supported mobile devices. Here is what I added to make that possible:

@media (hover: none), (hover: on-demand) {
    /** Remove transitions -- method via http://stackoverflow.com/questions/6634470/disable-turn-off-inherited-css3-transitions **/
    nav ul#pages li a i,
    nav ul#pages li a:hover i,
    nav ul#pages li a span,
    nav ul#pages:hover li a span {
        -webkit-transition: none;
        -moz-transition: none;
        -ms-transition: none;
        -o-transition: all 0 none;
        transition: none;
    }

    nav ul#pages li a span {
        opacity: 1;
    }
}

As you can see, I first remove all transitions from the icon and label, then make the label opaque when not in hover state. This leaves the existing hover effects intact for desktop while removing them for (most) devices that don’t fully support it. While this doesn’t catch every user, it catches enough that it makes a good foundation for future improvements to cover edge cases later on.

What About Firefox?

A comment posted just yesterday to the Firefox bug report reveals a non-standard feature, -moz-touch-enabled, that can do the trick using 1 for enabled and 0 for disabled:

@media (-moz-touch-enabled: 1) {
    /** Declarations go here **/
}

While it is a step in the right direction, this declaration also matches desktop Firefox users with touchscreen monitors. And because Firefox doesn’t support other media interaction features like pointer: coarse, we can’t currently target Firefox users with CSS-only with any more specificity. Unless you want to use a shim, it’s probably better to persuade Mozilla to join Microsoft, Apple and Google in supporting @media (hover: *) than use this feature in production, unless you don’t care about the above caveat.

Further Reading

Endnotes

  1. At this point, we would then need to decide whether the hover effect would even be needed, necessitating a redesign to avoid this problem.

1 thought on “Sticky Hovers, Part 1: @media Queries”

Leave a Reply

Your email address will not be published. Required fields are marked *