Creating a CSS only interactive site menu

Maciej Baron
Good Praxis
Published in
5 min readJul 13, 2022

Our team at Good Praxis has recently been tasked with redesigning and developing Peace & Justice Project’s new website. Things that many years ago would require Javascript to function — like lazy loading of images, menu transitions and so forth — are now possible to achieve using regular HTML and CSS. However it’s important to keep accessibility in mind, and not just try to “hack” your way through to your end goal.

For P&J we have created a CSS only site menu, which is fully accessible and navigable using a keyboard.

The goal was to have a “dropdown” menu with sections. When a user hovers over a section heading, a list of items appears below. We want the items to animate in and out, and just show and disappear immediately.

I’ll explain how we achieved this. Let’s start with the main HTML structure. Note that I left some Jinja2 for loops just to show how and where new entries would appear:

<nav class="main-menu">
<div class="main-menu__inner">
{% for section in menu %}
<div class="main-menu__section" tabindex="0">
<div class="main-menu__section-header">
{{section.title}}
</div>
<ul class="main-menu__list">
{% for menu_item in section.menu %}
<li>
<a href="{% menu_item.url %}">{{menu_item.name}}</a>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</nav>

Let’s break this down. We have our main nav element and an “inner” container inside it. For each section we then create a main-menu__section container, which has a header and a list of items.

Let’s look at the section container for a moment. Notice how it uses a tabindex property?

<div class="main-menu__section" tabindex="0">

This is so that this element can become focusable when tabbing through elements. Why this matters will become apparent in a moment.

Hiding and showing elements

As explained before, we want a list of items to appear (main-menu__list) when we hover over a section heading (main-menu__section-header). This means that initially, our list should be invisible.

Of course one way of achieving this is by using display: none. However, this is something that just can be toggled on and off, and we would like to have a transition between something being visible and invisible. Our obvious choice in this case will be to use opacity and transition between 0 and 1.

.main-menu__list {
opacity: 0;
transition: opacity .5s;
}

We know want to show the list when we hover over the heading. The header and the list are siblings, so we could use an adjacent sibling combinator:

.main-menu__section-header:hover+main-menu__list {
opacity: 1;
}

…however, there is a problem with this approach. The moment we try to click something in the list, it disappears. That’s because we are no longer hovering over the section header, but rather the list.

It’s running away!

You may think to yourself: “No problem, I’ll just expand the selector!” and create something like this:

.main-menu__section-header+main-menu__list:hover,
.main-menu__list:hover {
opacity: 1;
}

…but this can become quite messy very quickly. What we can do instead is simply use a descendant selector and apply a hover state to the whole section, rather than just the header:

.main-menu__section:hover .main-menu__list {
opacity: 1;
}

Much neater. Now whenever the mouse is hovering over the section, we show the list, regardless if it’s over the list itself, the header, or anything else within it.

…not running away anymore!

Invisible, but still there

The thing about opacity is that you can hide elements using it, but the element is still de facto there. It’s like having a window pane painted black, and then removing the paint itself. You can now see through the window; if it’s clean enough, you could probably fool yourself to think there’s nothing there. However, the glass is of course still there, and you can touch it and feel it.

Using display: none would be like removing the window pane, so there’s nothing left.

Why is using opacity potentially causing a problem here? Well, the list items are hidden, but they are still technically there, meaning… you can click on them! This definitely isn’t something that we want.

Luckily, there’s an easy solution to this problem. Namely, we can use pointer-events to make sure no pointer events are handled by the element — that means, no clicks, no hover events — nothing.

.main-menu__list {
opacity: 0;
pointer-events: none;
transition: opacity .5s;
}
.main-menu__section:hover .main-menu__list {
opacity: 1;
pointer-events: initial;
}

Pointer events have been supported since IE11, so there’s literally no reason for us to not use them.

Making it accessible

Now, remember how I mentioned tabindex before, and how it will become useful? Well, we want to make sure that users can navigate the menu by tabbing through elements. For that we can use :focus in our selector:

.main-menu__section:hover .main-menu__list,
.main-menu__section:focus .main-menu__list {
opacity: 1;
pointer-events: initial;
}

This is great — we press tab, and our menu section opens! Excellent. We press tab again and… oops! Our menu disappears. What’s going on? Well, we’re facing a similar situation to our hover problem from before — when we focus on a menu item (an anchor link) we don’t focus on the section anymore.

Disaster! Does this mean our menu can’t be accessible? Luckily, there is a simple remedy: using :focus-within which gets triggered when an element within the parent gains focus:

.main-menu__section:hover .main-menu__list,
.main-menu__section:focus .main-menu__list,
.main-menu__section:focus-within .main-menu__list {
opacity: 1;
pointer-events: initial;
}

This is well supported by modern browsers, unless you need to support IE11 (you shouldn’t really) or old versions of Edge (which have even less usage).

And there you have it! A simple, fully working website menu achieved without using JavaScript. Another neat thing about this approach: it also works on mobile.

This should give you all the building blocks you need to create your own menu. Now all you have to do is workout the rest of the layout, depending on how you want the menu to look like.

Remember: you don’t always need JavaScript to achieve certain things!

--

--