Make CSS drop-down menus work on touch devices

CSS drop-down menus are very popular on sites with a hierarchy of pages. They let you get to where you want to go without having to navigate the pages in that hierarchy. But pure-CSS menus suffer a problem: touch devices often can’t show the drop-down, because they don’t have “hover” and clicking on the top level link goes there. This snippet offers a way around that.

On modern touch devices like iPhones, iPads and Android phones and tablets, there is a series of events associated with a tap. You get a “touchstart” when you first tap, then a “touchend” when you lift your finger, and finally a “click” to simulate a mouse click. (There are more; see the W3C’s touch events candidate recommendation and Mozilla’s touch events page.)

What this snippet does is keep track of the first time you tap on a menu link, and suppress the click event for that tap. On the second tap, it lets the click event occur. This lets your website visitor tap to expand the drop-down menu, then tap again if they want to go to that link.

In this snippet, my menu has the id “menu” and menu items with children have the class “children”. Adjust the call to querySelectAll to fit your menu.

[edit: Chrome 17 just came out and, naturally, the code I had here gave a false positive so I've updated the touch test.]
[edit: because iOS (iPads, iPhones) now do this automatically since iOS 5, you need to be able to disable this script on those devices. I've augmented the script below to do that.]

// see whether device supports touch events (a bit simplistic, but...)
var hasTouch = ("ontouchstart" in window);
var iOS5 = /iPad|iPod|iPhone/.test(navigator.platform) && "matchMedia" in window;

// hook touch events for drop-down menus
// NB: if has touch events, then has standards event handling too
// but we don't want to run this code on iOS5+
if (hasTouch && document.querySelectorAll && !iOS5) {
    var i, len, element,
        dropdowns = document.querySelectorAll("#menu li.children > a");

    function menuTouch(event) {
        // toggle flag for preventing click for this link
        var i, len, noclick = !(this.dataNoclick);

        // reset flag on all links
        for (i = 0, len = dropdowns.length; i < len; ++i) {
            dropdowns[i].dataNoclick = false;
        }

        // set new flag value and focus on dropdown menu
        this.dataNoclick = noclick;
        this.focus();
    }

    function menuClick(event) {
        // if click isn't wanted, prevent it
        if (this.dataNoclick) {
            event.preventDefault();
        }
    }

    for (i = 0, len = dropdowns.length; i < len; ++i) {
        element = dropdowns[i];
        element.dataNoclick = false;
        element.addEventListener("touchstart", menuTouch, false);
        element.addEventListener("click", menuClick, false);
    }
}

OK, it’s a bit of a hack, but it means your website can become usable again for touch devices; hack job is done. Better would be a navigation system that doesn’t rely on hover events, which touch devices can’t give you, but that’s for your website’s redesign…

facebooktwittergoogle_plusredditpinterestlinkedinmailfacebooktwittergoogle_plusredditpinterestlinkedinmail
  • Jt

    Hack or not, it is necessary for a poor browser that is not able to view the real web without hacks. iOS does this automatically. Google, shame. SHAME. You are giving us more work, and making the user experience worse and less consistent! It is absolutely Microsoft-like.

    • Jt

      And by the way, you say we should redesign and have nav that doesn’t use dropdown… WHAT?! What do you suggest? There is no other mainstream method of including a large set of nav links. There is no other clean way to do it. All browsers should support this very basic behavior.

      • Jt

        I appreciate the script but I can’t get it to work! How frustrating :(

        • http://snippets.webaware.com.au/ rmckay

          You need to change the selector to match your menu. i.e.

          "#menu li.children > a"

          This selector matches a div or nav or ul with id=”menu” and li elements with class=”children”, indicating that there are child menu items within it. If your HTML has different IDs and classes, you need to change the selector to match.

          As for how to better design, I’m no design guru, but I like how these sites handle menus on smart phones:

    • K

      What did Google do? Android’s Chrome browser displays websites exactly as they appear on a computer. This issue has to do with the nature of touch devices.

      • K

        Never mind, I just realized what you mean—iOS has already implemented this first-tap system by default, and you’re right, that is an improvement. It will be nice when all touch browsers follow.

  • Nate

    Wow this is awesome, thanks heaps!

  • http://www.blackstudio.it Black Studio

    Hi there,
    I just released a WordPress plugin that automatically adds the behavior described in this article to a generic navigation dropdown menu for touch devices. The original idea was taken from here, but the code was completely rewritten from scratch. You can get the plugin from WordPress Repository: http://wordpress.org/extend/plugins/black-studio-touch-dropdown-menu/
    I would be glad to receive feedback about it.

    • http://snippets.webaware.com.au/ Ross McKay

      G’day Marco,

      Good work, I tried it on a test install and it works well. So, to the feedback :)

      * wrap that all up in a self-invoked function and pass in the jQuery object, so you can minify all object names down to 1 character throughout your script:
      (function($) { ... $(this) ... })(jQuery);

      * minify your script, it will drop the size down dramatically; I like this minifier:
      http://developer.yahoo.com/yui/compressor/

      * cache your jQuery objects in vars instead of throwing them away and recreating them each time:
      var $this = $(this); $this.foo(); $this.bar();

      * don’t call jQuery to do something the DOM can already do:
      this.focus(); // same as jQuery(this).focus();
      window.location = this.href; // same as window.location = jQuery(this).attr('href');

      * use action wp_enqueue_scripts instead of wp_print_scripts; the latter is for other purposes

      Remember that the script will be running on lower-power devices generally, so you want to minimise the work done in your script. It needs to run fast and not hit the processor too much. That’s actually why I didn’t use jQuery in my example, but your script is more general so it gets some benefit from using jQuery’s extended selector syntax. But try to avoid building and rebuilding jQuery objects, especially when you can just use a DOM property or function; jQuery has a cost.

      cheers,
      Ross

      • http://www.blackstudio.it Black Studio

        Hi Ross,
        thank you very much for taking the time to test the plugin and mostly for the useful tips. I’ve just implemented all your suggestions in the latest version of the plugin.
        Cheers.

        • http://snippets.webaware.com.au/ Ross McKay

          G’day Marco, looking good.

          I don’t know what tool you’re using to minify your script, but it isn’t reducing the variable names to single characters. YUICompressor does, and it reduces your script to 850 bytes against your 1240 bytes. Maybe your tool has an option you’re not using. If not, get a better minifier! :)

          cheers,
          Ross

          • http://www.blackstudio.it Black Studio

            Hi Ross,
            I used this JavaScript Compressor http://www.minifyjavascript.com/
            It has a packing/encoding option but when I tried it with the current script, the difference between the packed and the unpacked versions was minimal (45% vs 47%), so I stayed with the unpacked one.
            Then I tried YUI Compressor, as you suggested, and it performs much better, so I will use it from now on.
            Thanks again :-)

  • Bruce Wampler

    This is a very helpful piece of code, but you have not included any license terms. It would be very helpful if you could explicitly add either a public domain or GPL type license for this. I tried Black Studio’s WP plugin, and it works quite well. However, I do believe that it technically violates WordPress.org’s licensing policy as you have not explicitly released the code to the public – although it does not have a copyright notice, and it is clearly your intent to share this with everyone.

    • http://snippets.webaware.com.au/ Ross McKay

      G’day Bruce, thanks for the nudge. I’ve added a statement to the About page saying that code snippets in blog posts on this site are public domain, free for use any way you like, unless otherwise stated.

      These are just code snippets, shared for the interest of anyone that has hit the same problems I have. When I develop them further as part of a system, e.g. a WordPress plugin, I release that whole package under the GPL or similar copyright license. Most code on this site is what I consider to be trivial, and requiring further development. However, I appreciate the nudge to make this explicit, as I understand how some people can get very proprietary about even trivial creations (I read comp.lang.javascript!)

      cheers,
      Ross

    • http://snippets.webaware.com.au/ Ross McKay

      Incidentally, Black Studio’s plugin uses different code, it merely draws upon the ideas in this blog post. Marco has taken these concepts and developed them into a more generically applicable codebase using jQuery. If anything, I’d imagine that there’s a patent troll somewhere out there sharpening a knife that would have greater legal (if not moral) claim on Marco’s plugin :) (but I hope that if there is, I beat them to it with this blog post!)

      cheers,
      Ross

      • http://www.blackstudio.it Black Studio

        Thank you for having saved me in advance from a long and boring legal process! :-)

        • http://snippets.webaware.com.au/ Ross McKay

          No worries, I’ve got your back!

  • Hannes Papenberg

    Just a little heads up: I have this code in an external file and I’m loading it in the head. I couldn’t get it to work, until I noticed that the code of course executed way to early and the DOM wasn’t populated yet. So you might want to wrap the whole thing in a domready event. (I’m also using mootools in my code, which makes it a bit shorter. :-) )

    • http://snippets.webaware.com.au/ Ross McKay

      G’day Hannes,

      Yes, quite right. I should probably mention that somewhere in the post, will edit it when I get a chance.

      cheers,
      Ross

  • Webmasta

    What is the reason for this line?

    this.focus();

    With this line in Android 4.0.4 stock browser on Samsung Galaxy S Duos a weird effect can be observed, if a top menu item which has a submenu is touched, then the menu opens fine and then – if you do not click on a submenu item but in empty non-menu space somewhere on page – the whole screen gets empty and white, but the page seems to still be there, a “blind” click at an area which would contain another menu-item starts the navigation.

    If the focus() is left out, all fine. Is there a particual situation where the focus is required?

    PS. In Black Studio version there is even one more focus() call in “Click Handler”, why?

    • http://snippets.webaware.com.au/ Ross McKay

      G’day Webmasta, I’m running Android 4.0.4 on a Samsung Galaxy S II and don’t have the problem. One thing I seem to have missed in this post is that the CSS rules that depend on :hover need to be augmented with copies that use :focus for the kludge to work; must work that into an edit soon. What the call to .focus() is doing is triggering those CSS rules, to simulate a hover state.

      cheers,
      Ross

      • Webmasta

        Ross, thanks for quick reply and explaination. Seeing the problem on a not-yet-public WordPress site of mine. Will try to provide a simple static html testcase soon.

        • Webmasta

          Can’t reproduce the problem in a reliable way for now, sometimes the white screen appears, sometimes not.

          The only thing I can reproduce: Removing the focus() calls in both your code and Black Studio jQuery version obviously avoids the problem completely.

          Weird. Will let you know if I find a reliable testcase or a reason for this.

      • Webmasta

        Some more talking to myself, but I think I found the problem:

        If focus() is called “too early”, actually doesn’t matter if within touchstart or click event , the “white screen of death” can appear if you touch/click somewhere else on the page.

        Enhanced the script of Ross with some colors for easier debugging on Android browser and also added a timeout for focus() call: http://pastebin.com/sK1wiFNW (expires in 30 days)

        Requirements to get the “white screen”: A page which includes several stylesheets, this seems to keep the Android browser busy with whatever things for too long, can’t provide this page, it’s a not-yet-public customer site.

        My findings:
        a) click is fired after touchstart, so probably the focus() should be better called from click to avoid other problems, does not affect the “white screen” problem though
        b) with my above page a delay of at least 2.5 seconds is required before focus() can be called without causing “white screen” problem, both in click and touchstart

        Conclusion: Must be some Android browser timing/race condition problem under certain conditions with touchstart and focus. Some other guys have obviously ran into similar problems:

        touchstart -> focus kills Galaxy Note (4.0.3) browser:
        https://groups.google.com/forum/#!topic/phonegap/_nKMDYjgAV0

        Android keyboard not opening for input tag bound to touchstart:
        http://stackoverflow.com/questions/11899044/android-keyboard-not-opening-for-input-tag-bound-to-touchstart
        The provided “If I do some code like this” with added alert() here is similar to my setTimeout approach.

        Solution: None.

        Workaround: No focus() call. Adding a timeout is actually not an acceptable workaround.

        • http://snippets.webaware.com.au/ Ross McKay

          G’day Webmasta, very interesting! What happens if you change touchstart to touchend?

          cheers,
          Ross

        • http://snippets.webaware.com.au/ Ross McKay

          Ignore above; but: is there any difference when adding the click event handler with true instead of false? i.e. with useCapture so that it grabs the click event on the “way in” rather than whilst bubbling up?

          element.addEventListener("click", menuClick, true);

          I know, grasping here, but I don’t have a test case to play with :)

          cheers,
          Ross

          • Webmasta

            Both touchend instead touchstart and addEventListener(“click”, .. true) do not make a real difference. With touchend it seems that an even higher delay is required before the focus call.

            I consider this problem as a bug in Android 4.0.4 stock browser event handling now. If I find time I’ll try in a small app which loads my test-url in a webview on the phone attached to Android SDK and see if/which errors appear in log.

          • Webmasta

            Wrote a small app now which loads the test-url (had to sort out some http auth problem for this first).

            Running the app on the phone through Android SDK shows the page in a webview and after the touch on menu, then touch outside on page after focus(), the screen gets all black (vs. white in stock browser) and only the focused element is drawn with a few px padding around it (nothing is drawn in stock browser). Within this small padding the regular page is partially visible.

            No errors in log, no unusual events, no indication that something went wrong.

            Will stop at this point and use a variant of the script, rewritten in jQuery, without the focus() call.

            Still, thanks for your help.

          • http://snippets.webaware.com.au/ Ross McKay

            G’day Webmasta, it sounds like the way you need to go. I’d still like to see a test case for this error state, if you can manage it, because I can’t seem to make it happen.

            cheers,
            Ross

  • http://www.websuperheroes.co.uk Dave

    Hi there,

    thanks for a really useful code snippet. Have you had any success getting this to work for the Microsoft Surface at all?

    Initially it doesn’t seem to register as a touch device at all. I found that by changing:

    var hasTouch = (“ontouchstart” in window);
    to
    var hasTouch = (‘ontouchstart’ in window || window.navigator.msMaxTouchPoints);

    that the Surface is then identified as being a touch device – so far so good. But the drop down menus don’t fire off at all.

    I think its this line: element.addEventListener(“touchstart”, menuTouch, false); as “touchstart” isn’t recognised by the Surface. I tried some alternatives that I found such as adding the line

    element.addEventListener(“MSPointerDown”, menuTouch, false);

    I’ve tried using “selectstart”, “MSGestureStart” and “MSGestureTap” in place of “touchstart” but none seem to solve the problem.

    Any other thoughts around this would be hugely appreciated.

    Cheers

    Dave

    • http://snippets.webaware.com.au/ Ross McKay

      G’day Dave,

      I’ve been wondering about Windows 8 touch. What you’ve done is pretty much what I would have tried, except I’d have used this for the touch detect:

      var hasTouch = ("ontouchstart" in window || window.navigator.msPointerEnabled);

      According to Microsoft’s WebKit-to-Windows-8 cheatsheet, you should be able to just bind MSPointerDown (or MSPointerUp) to the touchstart event handler. Can you add some console logging or alert() calls to show whether the event handlers are in fact being called?

      Here’s a gist with those changes and some console logging. Are you able to show console logs in the app version on Windows 8 touch? Can you get to the desktop version on your touch device? (I know that in my Windows 8 VM, you can show the console on the desktop version)

      If you can’t get the console up in IE, replace the console.log calls with alert calls.

      Let me know how you go.

      cheers,
      Ross

    • http://snippets.webaware.com.au/ Ross McKay

      Dave, thinking about it further:

      * can you confirm that your code works in Android? (if not, maybe your CSS isn’t handling :focus)

      * have you tried without this code? i.e. does Windows 8 handle CSS menus the same way as iOS 5+

      cheers,
      Ross

    • http://snippets.webaware.com.au/ Ross McKay

      G’day Dave,

      I realised that I could force Windows 8 on desktop to use the pointer events in a test case, and it seems to work — first pointer down suppresses click event, second pointer down doesn’t. For my hasTouch test, I now have this:

      var hasTouch = ("ontouchstart" in window || ("msMaxTouchPoints" in navigator && navigator.msMaxTouchPoints > 0));

      Please try the gist.

      cheers,
      Ross

  • Jon Cole

    Would it be possible for you to post the snippet of your corresponding HTML? I’m apparently not getting my selectors set up properly. If I see where your HTML matches up with your Javascript, I can alter your code to work with mine.

    Thanks much!

    • http://snippets.webaware.com.au/ Ross McKay

      G’day Jon, have a look at this website, it’s one of the more simplistic ones we’ve done in recent times so you should be able to find your way around the markup easily. The CSS is minified, but the unminified CSS (compiled from SCSS) is here. I really must put together a simple demo case for this post some time…

      • Jon Cole

        Finally got around to checking this out (other pressing projects delayed my response). I made sure my navigation HTML matched what this site’s code and tried it with your javascript (checking to make sure my ID was set properly) and still had no joy. So, after looking through the JS files on the site you had me look at, I downloaded theme.min.js and simplebase.min.js, changed the ID in theme.min.js to match what I’m using, then included them in my site.

        It works now.

        Apparently, I’ve implemented the code you originally provided incorrectly, but at least now my site works on Android devices.

        It’s odd to me that when I search for CSS menus not working in Android, I seem to get just the opposite results (ie iOS not working). How is it that others aren’t seeing or talking about this issue? Anyway, that’s not really my point I guess…more like thinking out loud.

        Thank you so much for your help!

        • http://snippets.webaware.com.au/ Ross McKay

          G’day Jon,

          Note that when you see a .min.js there is usually also a .js that hasn’t been minified. I drop my simplebase.js into most sites I build as a base for some simple stuff I put in the theme.js script, without needing to rely on jQuery (e.g. for a lighter download on mobiles). You should be able to work out from theme.js (not the minified one) what’s going on. Glad to hear you got it all working!

          cheers,
          Ross

  • http://maxw3st.us/ maxw3st

    I think the idea needs to be to come up with a way to simulate the hover event in a touch device, not remove hover functionality from websites. We need a pseudo class in CSS to do this, not a page of JavaScript.

    That said, thank you for this workaround. Since drop down menus are ubiquitous and therefore well understood by users, they need to be made functional on touch screens as well as for those using pointers and keyboards.

    • http://snippets.webaware.com.au/ Ross McKay

      G’day @max3st,

      I don’t think “simulating the hover event” is really an answer; touch doesn’t work that way, there is no event until you tap the screen so following where someone’s finger is going just isn’t going to happen (not any day soon, anyway!)

      A better solution is to design user interfaces with this in mind, and thus not relying on hidden things that only appear when you pass a mouse over them. Good touch-friendly interfaces do this already, hover-based drop-down menus just aren’t useful in a touch-based environment and need to be adjusted.

      This script is really about hacking a design that doesn’t properly work on touch, and making it at least accessible to people with touch devices. It’s a stop-gap measure, not a solution.

      cheers,
      Ross

  • http://www.hoschies.org Spatze

    Thx for the tips here. I had the problem on all Android devices (did not experience the problem on iOS, though). Finally the Black Studio WordPress plugin solved it – off the shelf, without amending the code. Great work from these guys. I wonder if this is working with Win8 touch devices.

    Cheers,

    Spatze

    • http://snippets.webaware.com.au/ Ross McKay

      G’day Spatze,

      Are you able to test with Windows 8 touch? If so, please try this gist: https://gist.github.com/webaware/3990308

      cheers,
      Ross

      • http://www.hoschies.org Spatze

        Sorry no, haven’t found anyone who is using win8 on mobile devices. Keep on, Ross!

  • iGadget

    I recreated a jQuery version that actually works and also checks for submenus:


    ub.hoverEvents = function(theMenuLinks) {
    // see whether device supports touch events (a bit simplistic, but...)
    var hasTouch = ("ontouchstart" in window);
    var iOS5 = /iPad|iPod|iPhone/.test(navigator.platform) && "matchMedia" in window;
    theMenuLinks = (theMenuLinks) ? theMenuLinks : "#menu li a"; // default Path

    // only start if 0) {

    event.preventDefault();

    // toggle flag for preventing click for this link
    var i, len;

    // reset flag on all links
    for (i = 0, len = dropdowns.length; i < len; ++i) {
    $(dropdowns[i]).data('noclick',false);
    }

    // set new flag value and focus on dropdown menu
    $(this).data('noclick', true);
    //this.focus();
    } else
    window.location.href = $(this).attr('href');
    }

    for (i = 0, len = dropdowns.length; i < len; ++i) {
    element = dropdowns[i];
    $(element).data('noclick', false);
    $(element).on("click ontouchstart", menuTouch);
    }
    }
    };

    Enjoy!

    • http://snippets.webaware.com.au/ Ross McKay

      Good work :)

  • Pingback: Touch-Friendly | Tom McGee's Blog

  • http://www.1stwebdesigns.com Web Design Leicester

    If you’re using jQuery, you can use this code to detect the tap of a link which might have a dropdown, and cancel the event if the dropdown is visible. This also makes it safe where you might have a selection of links that have dropdowns and some that don’t:


    $("#nav-main > ul > li > a").click(function() {
    if ($(this).parent().find("ul").is(":visible")) {
    return false;
    }
    });

    You’ll need to replace the selectors with whatever’s relevant in your scenario.

  • Lukas

    Hello. I know is a silly question, but I do not know much about css. I created a website in joomla and I wish to add this code, to make my dropdown menu works on tablets and mobiles, but I do not know where. Can some one tell me please, which file I should edit to make it works?

    • http://snippets.webaware.com.au/ Ross McKay

      G’day Lukas,

      I don’t do Joomla! so I can only point you at the documentation, or this tutorial.

      cheers,
      Ross

  • Leon

    Hi Ross,

    First, thank you for sharing! I’m going crazy trying to get this to work as js is not my strength. I used your code, however, the tap is still triggering the link (testing on Android). My menu structure is as follows:


    <a href="">Item 1</a>
    <a href="">Item 2</a>
    <a href="">Sub Item 1</a>
    <a href="">Sub Item 2</a>

    I’ve used several variations of “#nav li.children > a”, “.nav li.children > a”, “#nav ul a”, “.nav ul a” and none of them seem to work. What am I missing?

    In advance I appreciate your help.

    • http://snippets.webaware.com.au/ Ross McKay

      G’day Leon,

      One thing I seem to have missed in this post is that the CSS rules that depend on :hover need to be augmented with copies that use :focus for the kludge to work; must work that into an edit soon.

      cheers,
      Ross

  • Jaime Carrión

    Good stuff! Thanks for sharing!

  • David Brown

    I used this in conjunction with Modernizr to isolate it to the devices I needed. Works beautifully. Thanks!