A pure CSS onclick menu

by Koen Kivits

12. February 2014

We’ve all seen the pure CSS dropdown menus that open on mouseover using the :hover pseudo-class. However useful these menus can be, they can make your page feel jumpy and they might not work for touch interaction. Can we do better?

Of course we can! Here’s an example:

.. and that’s without a single line of Javascript!

Note: I’m sorry to say this, but if your on iOS this technique won’t work. See Cons for an explanation.

Getting started

The basic idea is actually pretty simple: we’ll just swap out :hover for :focus. First we’ll need some basic HTML to get started:

<div tabindex="0" class="onclick-menu">
    <ul class="onclick-menu-content">
        <li><button onclick="alert('click 1')">Look, mom</button></li>
        <li><button onclick="alert('click 2')">no JavaScript!</button></li>
        <li><button onclick="alert('click 3')">Pretty nice, right?</button></li>
    </ul>
</div>

Noticed the tabindex="0"? It’s there to make sure our little menu can receive focus (the ‘0’ value sets the tabindex to the order of appearance).

Then we just need some basic CSS to get started:

.onclick-menu {
    position: relative;
    display: inline-block;
}
.onclick-menu:before {
    content: "click me!";
}
.onclick-menu:focus .onclick-menu-content {
    display: block;
}
.onclick-menu-content {
    position: absolute;
    z-index: 1;

    display: none;
}

And here’s our resulting menu:

And that’s it! Almost.

Making it click

You may notice that none of the click handlers actually work. That’s because the menu disappears as soon as it loses focus, and it loses focus as soon as a mousedown fires on a focussable element (like a <button>). Because the menu disappears, the <button> disappears. This means there is no <button> to fire a mouseup and click on, and so our handlers are never called.

Can we work around this? Again: of course we can! We’ll just use a transition, delaying the hiding until the click is done. Transitions aren’t supported for the display property, but we can use visibility just as well — the menu content has position: absolute anyway.

Here’s our new CSS:

.onclick-menu {
    position: relative;
    display: inline-block;
}
.onclick-menu:before {
    content: "click me!";
}
.onclick-menu:focus .onclick-menu-content {
    /* content is visible if menu is 'opened' */
    visibility: visible;
}
.onclick-menu-content {
    position: absolute;
    z-index: 1;

    /* disable visibility by default, delay to enable clicks */
    visibility: hidden;
    transition: visibility 0.5s;
}

.. and this is our current result:

Finishing touches

Now there are just 2 itches we need to scratch before we can call this menu finished. Firstly, the menu feels sluggish because of the transition. Secondly, the menu doesn’t close when clicking the label again, and as a user I expect that to happen.

The slowness we can fix by cheating a little bit. We want to hide the menu immediately, but we don’t want to use a property that removes the ability to click. Well, opacity does just that. We can’t use it by its own because it would make content below the menu unclickable when closed, but we can use it combined with the visibiliy transition.

We can also make the menu close by clicking on the label again, this time by using pointer-events. We’ll let the menu have pointer-events: none in its opened state, preventing a click on the menu label from maintaining the focus on the menu. pointer-events aren’t widely supported yet, but it degrades rather gracefully by just removing this single feature.

Putting it all together:

.onclick-menu {
    position: relative;
    display: inline-block;
}
.onclick-menu:before {
    content: "click me!";
}
.onclick-menu:focus {
    /* clicking on label should toggle the menu */
    pointer-events: none;
}
.onclick-menu:focus .onclick-menu-content {
    /*  opacity is 1 in opened state (see below) */
    opacity: 1;
    visibility: visible;

    /* don't let pointer-events affect descendant elements */
    pointer-events: auto;
}
.onclick-menu-content {
    position: absolute;
    z-index: 1;

    /* use opacity to fake immediate toggle */
    opacity: 0;
    visibility: hidden;
    transition: visibility 0.5s;
}

… and that’s it, we’re done!

Pros

Our little menu has some nice features right out of the box:

  • it requires very little HTML and not much CSS (and of course: not a single line of Javascript)
  • good browser support (even IE8 if you don’t mind the transition issues)
  • it can be accessed with the keyboard tab button
  • it closes when clicking anywhere in the document, but not when clicking the menu itself
  • it closes when clicking any of the focussable items in it

Cons

Unfortunately, this solution isn’t perfect either. I found 2 downsides with it:

  • this may be a big issue for a lot of you: it doesn’t work with iOS Safari, as it doesn’t seem to focus elements that aren’t text inputs on touch. You can fix the opening of the menu by adding on empty onclick handler to the top element, though you will need more JS make the menu close when clicking anywhere else. This kind of defeats the point. It works fine on Android’s stock browser, Mobile Firefox and Chrome for Android.
  • it only works for ‘short’ clicks. By this I mean that the mouseup must follow the mousedown pretty quickly (within the 0.5 seconds), otherwise the menu closes before any click event is fired. You could tweak the transition timing a bit, or you could still cheat a little bit of JS.

The future

This little hack is far from perfect and it might not suit your needs. It does indeed break the border between content and presentation a bit and there are still the cons I mentioned. Still, I thought it was worth sharing.

Please do know that this type of functionality is coming to HTML itself. It doesn’t look like we’ll be able to use the standardized version soon, though, as current browsers don’t support it at all.

For now, I hope you liked this trick. Check out the full source for the example on Codepen!

Update 13. february 2014: I’ve received some comments on Reddit. I slighty updated the article in response — I changed my statement on how :hover doesn’t work for touch and added a notion about breaking the barrier between content and presentation.

comments powered by Disqus