DEV Community

loading...

Create an accessible dropdown navigation

lkopacz profile image Lindsey Kopacz Originally published at a11ywithlindsey.com Updated on ・6 min read

Hover navigations are pretty simple to do without JavaScript, which is how I usually see them implemented. The HTML and CSS are pretty simple.

HTML:

<nav>
  <ul class="menu">
    <li class="menu__item">
      <a href="/" class="menu__link">About</a>
      <ul class="submenu">
        <li class="submenu__item">
          <a class="submenu__link" href="/our-mission">Our Mission</a>
        </li>
        <li class="submenu__item">
          <a class="submenu__link" href="/our-team">Our Team</a>
        </li>
      </ul>
    </li>
  </ul>
</nav>
Enter fullscreen mode Exit fullscreen mode

CSS:

.submenu {
  position: absolute;
  left: 0;
  padding: 0;
  list-style: none;
  height: 1px; 
  width: 1px;
  overflow: hidden;
  clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
  clip: rect(1px, 1px, 1px, 1px);
}

.menu__item:hover .submenu {
  padding: 0.5rem 0;
  width: 9rem;
  height: auto;
  background: #eedbff;
  clip: auto;
}
Enter fullscreen mode Exit fullscreen mode

Note: I have used the visually-hidden styling instead of display: none. This is important for accessibility, and you can read more in the link above.

I've taken out some of the general styling, but this CSS is what contributes to the hover effect. However, as you can see with the gif below, it doesn't work the same way if you use your tab key.

Gif of mouse hovering over navigation displaying the submenu, and the top level items receiving focus and not doing anything.

Before we jump into coding, I wanted to share my approach to this problem. First, I want to solve the problem of opening the nav on not only on hover but also on focus. Second, I want to ensure that on focus each submenu "opens" as it does with the hover. Third, I want to make sure that once I tab through the links, that particular submenu closes when I leave it. Now let's get started!

Replicating the hover effect on focus

Because we have the :hover pseudo-class on the li element, we should also target our focus on the li element. But if you read my blog post on Keyboard Accessibility, you'll recognize the concept of tabindexes. li elements do not have tabindexes, but links do. What I personally like to do is target the top level links in JavaScript and add a class to their parents on a focus event. Let's walk through that a little further.

const topLevelLinks = document.querySelectorAll('.menu__link');
console.log(topLevelLinks);
Enter fullscreen mode Exit fullscreen mode

Google Chrome console displaying a NodeList of the menu link class.

When I console.log the variable, I get a node list of the top menu items. What I like to do is loop through those using a forEach loop and then log each of their parentElement's.

topLevelLinks.forEach(link => {
  console.log(link.parentElement);
});
Enter fullscreen mode Exit fullscreen mode

Google Chrome console displaying all the top level list items elements.

Now what I want to do is add a focus event listener to the link, and then console.log this to ensure to double check that we have the correct context of this.

topLevelLinks.forEach(link => {
  link.addEventListener('focus', function() {
    console.log(this);
  });
});
Enter fullscreen mode Exit fullscreen mode

Google Chrome console displaying the About link on focus, as that is the context of this.

I am using an old-school function (instead of an ES6+ arrow function) because I want to ensure the context of this is the target. There are plenty of blog posts about this (haha, see what I did there) if you'd like to read more on it. Anyways, now I'd like to have it so that we are targeting the parentElement of this, which is the li.

topLevelLinks.forEach(link => {
  link.addEventListener('focus', function() {
    console.log(this.parentElement);
  });
});
Enter fullscreen mode Exit fullscreen mode

Google Chrome console displaying the list item for About on focus, as that is the context of the parent of this.

This parent element is what we need to target. What I am going to do is add a class to the li that we logged to the console. Then what I will do is use a CSS class to replicate the styling we have on :hover.

topLevelLinks.forEach(link => {
  link.addEventListener('focus', function() {
    this.parentElement.classList.add('focus');
  });
});
Enter fullscreen mode Exit fullscreen mode

Gif displaying the adding of the focus class as we tab to the top level menu items.

.menu__item:hover .submenu,
.menu__item.focus .submenu {
  padding: 0.5rem 0;
  width: 9rem;
  height: auto;
  background: #eedbff;
  clip: auto;
}
Enter fullscreen mode Exit fullscreen mode

https://media.giphy.com/media/1k4svGjvxOSwCdijta/giphy.gif

As you'll see, the menu doesn't close after we leave it which is one of our action items that I laid out. Before we do that, let's take a second to learn about the blur event and what that means.

The Blur Event

Per Mozilla docs, the blur event is fired when an element loses focus. We want to keep the submenu open until the last submenu item loses focus. So what we need to do is remove the focus class on blur.

The first thing I like to do is within that forEach loop we have, is to check if there is a nextElementSibling.

topLevelLinks.forEach(link => {
  link.addEventListener('focus', function() {
    this.parentElement.classList.add('focus');
  });

  console.log(link.nextElementSibling);
});
Enter fullscreen mode Exit fullscreen mode

Google Chrome console displaying 2 unordered list elements with the class of submenu and null.

Next what I will do is create a conditional. We only want to run the following code IF there is a submenu. Here is what I did:

topLevelLinks.forEach(link => {
  link.addEventListener('focus', function() {
    this.parentElement.classList.add('focus');
  });

  if (link.nextElementSibling) {
    const subMenu = link.nextElementSibling;
    console.log(subMenu);
    console.log(subMenu.querySelectorAll('a'));
  }
});
Enter fullscreen mode Exit fullscreen mode

Google Chrome console displaying both the unordered list elements with the class of submenu and the NodeLists associated with the links below them.

The reason I log both the subMenu and the querySelectorAll is for visual learning. It's good for me to see that I have both submenu elements targeted correctly, as well as the NodeList for the links within them. So what I want to do here is target the last link in that querySelectorAll. Let's put it into a variable to make it more readable.

topLevelLinks.forEach(link => {
  link.addEventListener('focus', function() {
    this.parentElement.classList.add('focus');
  });

  if (link.nextElementSibling) {
    const subMenu = link.nextElementSibling;
    const subMenuLinks = subMenu.querySelectorAll('a');
    const lastLinkIndex = subMenuLinks.length - 1;
    console.log(lastLinkIndex);
    const lastLink = subMenuLinks[lastLinkIndex];
    console.log(lastLink);
  }
});
Enter fullscreen mode Exit fullscreen mode

Google Chrome console displaying the index number of the last link item and the element of the last link.

On each of these last links, we want to add a blur event that removes the class from that li. First, let's check out the link.parentElement to ensure that we are getting what we expect.

topLevelLinks.forEach(link => {
  link.addEventListener('focus', function() {
    this.parentElement.classList.add('focus');
  });

  if (link.nextElementSibling) {
    const subMenu = link.nextElementSibling;
    const subMenuLinks = subMenu.querySelectorAll('a');
    const lastLinkIndex = subMenuLinks.length - 1;
    const lastLink = subMenuLinks[lastLinkIndex];

    lastLink.addEventListener('blur', function() {
      console.log(link.parentElement);
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Gif displaying the parent element in the console after we tab away from the last item in that submenu.

Now that we have what we expect, I am going to do the opposite that I do on the focus event listener.

topLevelLinks.forEach(link => {
  link.addEventListener('focus', function() {
    this.parentElement.classList.add('focus');
  });

  if (link.nextElementSibling) {
    const subMenu = link.nextElementSibling;
    const subMenuLinks = subMenu.querySelectorAll('a');
    const lastLinkIndex = subMenuLinks.length - 1;
    const lastLink = subMenuLinks[lastLinkIndex];

    lastLink.addEventListener('blur', function() {
      link.parentElement.classList.remove('focus');
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Gif showing menu that opens and closes when we tab through the links and the submenu.

One last thing I am going to do is place the focus event listener within that conditional statement. The reality is that we don't need to add a focus class to an item that doesn't have a submenu.

topLevelLinks.forEach(link => {
  if (link.nextElementSibling) {
    link.addEventListener('focus', function() {
      this.parentElement.classList.add('focus');
    });

    const subMenu = link.nextElementSibling;
    const subMenuLinks = subMenu.querySelectorAll('a');
    const lastLinkIndex = subMenuLinks.length - 1;
    const lastLink = subMenuLinks[lastLinkIndex];

    lastLink.addEventListener('blur', function() {
      link.parentElement.classList.remove('focus');
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Additional Challenges

This blog post is getting VERY long, so maybe I'll do a follow-up post next week. The one thing I haven't solved here that I'd like to in my follow-up post is how to go backward in the menu. If you use the tab and shift key simultaneously, this doesn't work when going back in the menu. If you want an additional challenge, try it out yourself!

So that's it for now! I'd love to see how you come up with a solution to this if it's different from mine. Let me know on Twitter what you think!

Discussion (27)

pic
Editor guide
Collapse
equinusocio profile image
Mattia Astorino

Nice! Just note that keyboard navigation is not the only thing to consider for accessibility. You should also consider to add aria attributes and implement aria widgets.

You may find useful this resource:
w3.org/TR/wai-aria-practices-1.2/

Collapse
lkopacz profile image
Lindsey Kopacz Author

totally! I actually presented about aria stuff a while back and I feel like it would be a good extension of this blog post!

Collapse
lkopacz profile image
Lindsey Kopacz Author

Additionally, I tend to use aria as a fallback since HTML5 is so great now <3

Collapse
equinusocio profile image
Mattia Astorino • Edited

Aria attributes are mandatory to support screen readers. Using semantic tags is not enough ☺️

EDIT
Since people doesn't read but go for interpretation i will explain and translate my really simple statement:
If you need to provide extra informations to SR, your are obliged (so why they are mandatory) to use aria attributes. No one will save you.

Thread Thread
lkopacz profile image
Lindsey Kopacz Author

After talking with blind users (IE the ones who use screenreaders most), they tell me they prefer HTML5 and add ARIA in afterward if more context is needed.

Sometimes manual testing I learn that it's not always mandatory, but if someone is visually impaired is telling me it's not mandatory, I am going to listen to them.

A lot of times ARIA isn't mandatory, it just adds in extra context when things aren't super clear. HTML5 is built to be accessible out of the box, it's when we create advanced features is when we need ARIA.

Thread Thread
equinusocio profile image
Mattia Astorino • Edited

Wait :). They aren’t mandatory when you don’t need to provide more informations. But if use for example a nav element as tab widget you must tell to SR which element is selected. And you can do that through aria-states and roles. These are design patterns defined inside the WCAG guidelines and we must follow them to be WCAG AA(A) compliant.

Another example is when you have an interactive element without content (and this is not an advanced feature), you need to provide the aria-label attribute to describe the action to SR.

In a multilevel menu (pure semantic html) you must tell elements relationship to SR otherwise users won’t know where they are... even in this case you should use aria attributes and roles to avoid the UI pollution.

Btw good job with this article. 👍🏻

Thread Thread
rachlivero profile image
Rachel Olivero

I think any blanket statement that ARIA attributes are required is dangerous. For example, saying that ARIA is required on a form field where a element is already correctly implemented is at best redundant work for the developer, and possibly results in extraneous verbosity for the screen reader user. (Trust me, as a screen reader user, I don't want more speech than I need when I'm trying to work efficiently.) While there is definitely a place for ARIA, and a site-wide nav menu that implements full arrow-key navigation would definitely benefit from this enhancement, ARIA is not required in all situations.

Thread Thread
equinusocio profile image
Mattia Astorino • Edited

I just said that aria attributes are mandatory because you can't provide informations to SR (when obviously there is the correct situation) in other ways. You MUST use them to provide additional and useful informations, you can't avoid to use them in such situations.

Thread Thread
lkopacz profile image
Lindsey Kopacz Author

I think you should listen to the person who uses a screenreader daily.....

Thread Thread
equinusocio profile image
Mattia Astorino

I do. Please don't be arrogant because you don't know how i work and what i do daily. I'm just telling that there are official specs, guidelines, certifications that we must follow.

To close this ridiculous aggresion, i say again nice article. Bye.

Thread Thread
lkopacz profile image
Lindsey Kopacz Author

I talked to her and she told me where I could improve with ARIA. She asserted that from her experience as a daily screen reader user that saying they are mandatory is a bit dangerous. That's not saying any of us disagree with your assertion. I also find you're being kinda aggressive by stating that us explaining our experience is aggressive.

Not sure how me asking you to listen to her is arrogant on my part. But bye, I guess?

Thread Thread
equinusocio profile image
Mattia Astorino • Edited

It’s arrogant and aggressive saying that i should “talk” to who use screen readers for real.. supposing that I’m not already doing it and it wasn’t a question. I never said we must use aria-attributes everywhere. Peace and love.

Thread Thread
theworstdev profile image
Kurt Kemple

"Aria attributes are mandatory to support screen readers. Using semantic tags is not enough" sounds like you're saying we have to use aria attributes everywhere. It's pretty arrogant to make such blanket statements.

Thread Thread
maxwell_dev profile image
Max Antonucci

Recommending you listen to people who regularly use screen readers, and likely have more knowledge and experience to add to ours, isn't arrogant or aggressive. It's a reasonable suggestion and a way to be more empathetic as a developer. Calling that "ridiculous" seems more ridiculous to me.

Thread Thread
equinusocio profile image
Mattia Astorino • Edited

No Kurtis Kemple. Sounds like if you need to provide useful informations to SR you need (are mandatory) aria widgets, states and attributes because there aren’t other ways to do that. It’s so hard to understand? I think they just misunderstood my words without asking what i was meaning.

Thread Thread
ben profile image
Ben Halpern

Hey can we all cool it here folks?

As an outsider to this conversation I definitely feel like people are talking past each other.

Thanks a lot.

Thread Thread
equinusocio profile image
Mattia Astorino • Edited

Yeah. Sorry, it was my fault because i was not so clear with my previous messages (🤔).

Collapse
link2twenty profile image
Andrew Bone

Do you know about focus-within?

You could do something like this and still have full keyboard navigation

.menu__item:hover .submenu,
.menu__item:focus-within .submenu {
  padding: 0.5rem 0;
  width: 9rem;
  height: auto;
  background: #eedbff;
  clip: auto;
}

Tweeked example on jsfiddle

Oh, and good to see you got the syntax highlighting working 🙂

Collapse
lkopacz profile image
Lindsey Kopacz Author

Hmm interesting. I'll check it out. What's the support like?

Collapse
lkopacz profile image
Lindsey Kopacz Author

Ugh yeah, the reason I haven't used this is because most of my clients still support IE and Edge. Me: sobs in a corner.

Thanks for showing me this though. Pretty cool to learn about how people are using pseudo-classes.

Thread Thread
link2twenty profile image
Andrew Bone

I'd probably still use the CSS method, I try to avoid JS where I can, if I could and have your method as a backup in case focus-within wasn't supported.

try {
  document.querySelector(':focus-within');
} catch (err) {
  console.log("focus-within is not available, using polyfill");
  focusWithinFallback(topLevelLinks);
}

function focusWithinFallback(array) {
  array.forEach(link => {
    ...
  }
}
.menu__item:hover .submenu,
.menu__item:focus-within .submenu,
.menu__item.focus .submenu {
  padding: 0.5rem 0;
  width: 9rem;
  height: auto;
  background: #eedbff;
  clip: auto;
}

This would give you support back to IE6 😀
Unparsable CSS is ignored by default.

Thread Thread
lkopacz profile image
Lindsey Kopacz Author

I'm a front-end dev, I am aware that unparsable CSS is ignored by default ;)

Thread Thread
link2twenty profile image
Andrew Bone

It was more for people that might read later 😜

Thread Thread
lkopacz profile image
Lindsey Kopacz Author

Yeah, I definitely understand the avoiding JS by default mindset. For me, I usually think this way too. My rule of thumb is always to use JS to toggle classes and use CSS to change the styling vs control the styling in JS. Over the years though, I've been less resistant to use JS depending on circumstance so long as I'm writing it in a minimalistic style.

I'll play around more with :focus-within for my follow up post :).

Collapse
moopet profile image
Ben Sinclair

I tried this (which is admittedly a bit messy) using relatedTarget on the event to determine whether the link was headed to another menu- or submenu-item. I'm also clearing all the top level focus classes whenever the top level menu is re-focused... it seems to work.

topLevelLinks.forEach(link => {
    if (link.nextElementSibling) {
      link.addEventListener('focus', function() {
        topLevelLinks.forEach(link => {
          link.parentElement.classList.remove('focus');
        });

        link.addEventListener('blur', function(e) {
          link.parentElement.classList.remove('focus');
        });

        this.parentElement.classList.add('focus');
      });

      const subMenu = link.nextElementSibling;
      const subMenuLinks = subMenu.querySelectorAll('a');

      subMenuLinks.forEach(link => {
        const topLevelLink = link.parentElement.parentElement.parentElement;

        link.addEventListener('focus', function() {
          topLevelLink.classList.add('focus');
        });

        link.addEventListener('blur', function(e) {
          if (e.relatedTarget) {
            const targetTopLevelLink = e.relatedTarget.parentElement.parentElement.parentElement;

            if (targetTopLevelLink != topLevelLink) {
              topLevelLink.classList.remove('focus');
            }
          }
        });
      });
    }
  });
Collapse
nlynchbs profile image
Nicole

Hi, this is great. I found your follow up CSS-only solution using :focus-within. But wondered if you posted the follow up JS solution for traversing backwards through the menu?

Collapse
lkopacz profile image
Lindsey Kopacz Author

I haven't gotten to it yet, maybe my next blog post 🤔