loading...
Cover image for CSS-Only Accessible Dropdown Navigation Menu

CSS-Only Accessible Dropdown Navigation Menu

5t3ph profile image Stephanie Eckles Updated on ใƒป8 min read

Modern CSS Solutions to Old CSS Problems (20 Part Series)

1) Keep the Footer at the Bottom: Flexbox vs. Grid 2) Equal Height Elements: Flexbox vs. Grid 3 ... 18 3) CSS-Only Full-Width Responsive Images 2 Ways 4) Pure CSS Smooth-Scroll "Back to Top" 5) Totally Custom List Styles 6) Animated Image Gallery Captions with Bonus Ken Burns Effect 7) CSS-Only Accessible Dropdown Navigation Menu 8) โœจ Announcing ModernCSS.dev 9) Solutions to Replace the 12-Column Grid 10) CSS Button Styling Guide 11) Icon Button CSS Styling Guide 12) Resource: The Complete Guide to Centering in CSS 13) Generating `font-size` CSS Rules and Creating a Fluid Type Scale 14) Container Query Solutions with CSS Grid and Flexbox 15) Expanded Use of `box-shadow` and `border-radius` 16) 3 CSS Grid Techniques to Make You a Grid Convert 17) 3 Popular Website Heroes Created With CSS Grid Layout 18) Announcing Style Stage: A Community CSS Showcase 19) Pure CSS Custom Styled Radio Buttons 20) Pure CSS Custom Checkbox Style

This is the seventh post in a series examining modern CSS solutions to problems I've been solving over the last 13+ years of being a frontend developer. Visit ModernCSS.dev to view the whole series and additional resources.

This technique explores using:

  • Animation with CSS transition and transform
  • Using the :focus-within pseudo-class
  • CSS grid for positioning
  • dynamic centering technique
  • Accessibility considerations for dropdown menus

If you've ever pulled your hair out dealing with the concept of "hover intent", then this upgrade is for you!

Before we get too far, while our technique 100% uses only CSS, some use cases may benefit from a tiiinnnyyy bit of vanilla JS to create just a bit better experience for mobile users in particular. There is also a polyfill needed for a key feature to make this work - :focus-within - for the most reliable support. But we've still greatly improved from the days of needing one or more jQuery plugins to accomplish this. So let's get started!

If you've not used Sass, you may want to take five minutes to understand the nesting syntax of Sass to most easily understand the code samples given.

Base Navigation HTML

We will enhance this as we continue, but here's our starting structure:

<nav aria-label="Main Navigation">
  <ul>
    <li><a href="#">About</a></li>
    <li class="dropdown"><span class="dropdown__title" id="dropdown-title">Sweets</span>
      <ul class="dropdown__menu" aria-labelledby="dropdown-title">
        <li><a href="#">Donuts</a></li>
        <li><a href="#">Cupcakes</a></li>
        <li><a href="#">Chocolate</a></li>
        <li><a href="#">Bonbons</a></li>
      </ul>
    </li>
    <li><a href="#">Order</a></li>
  </ul>
</nav>

This is the semantic standard for navigation links. This structure is flexible to live anywhere on your page, so it could be a table of contents in your sidebar as easily as it is a main navigation.

Right out the gate, we have implemented two features specifically for accessibility:

  1. aria-label on the <nav> to help identify it's purpose when assistive tech is used to navigate a page by landmarks
  2. aria-labelledby on the .dropdown__menu that links to the id of the .dropdown__title to associate it and allow assistive tech to read something like "link, Donuts list Sweets 4 items level 2" where "Sweets" is the dropdown title value

Our (mostly) default starting appearance is as follows:

default list of links

Initial Navigation Styles

First, we'll give some container styles to nav and define it as a grid container. Then we'll remove default list styles from the nav ul and nav ul li.

nav {
  background-color: #eee;
  padding: 0 1rem;
  display: grid;
  place-items: center;

  ul {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;

    li {
      padding: 0;
    }
  }

}

navigation list with list styles removed

We've lost the hierarchical definition, but we can begin to bring it back with the following:

nav {
  // ...existing styles

  > ul {
    grid-auto-flow: column;

    > li {
      margin: 0 0.5rem;
    }
  }
}

By using the child combinator selector > we've defined that the top-level ul which is a direct child of nav should switch it's grid-auto-flow to column which effectively updates it to be along the x-axis. We then add margin to the top-level li elements for a bit more definition. Now, the future dropdown items are appearing contained below the "Sweets" menu and are more clearly its children:

nav list with direct child styles

Next we'll add a touch of style first to all links as well as the .dropdown__title, then to only the top-level links in addition to the .dropdown__title:

nav {
  > ul {

    > li {
      // All links contained in the li
      a,
      .dropdown__title {
        text-decoration: none;
        text-align: center;
        display: inline-block;
        color: blue;
        font-size: 1.125rem;
      }

      // Only direct links contained in the li
      > a,
      .dropdown__title {
        padding: 1rem 0.5rem;
      }

    }
  }
}

updated link styles

Base Dropdown Styles

We have thus far been relying on element selectors, but we will bring in class selectors for the dropdown since there may be multiple in a given navigation list.

We'll first style up the .dropdown__menu and its links to help identify it more clearly as we work through positioning and animation:

.dropdown {
  position: relative;

  .dropdown__menu {
    background-color: #fff;
    border-radius: 4px;
    box-shadow: 0 0.15em 0.25em rgba(black, 0.25);
    padding: 0.5em 0;
    min-width: 15ch;

    a {
      color: #444;
      display: block;
      padding: 0.5em;
    }
  }
}

dropdown__menu styles

One of the clear issues is that the .dropdown__menu is affecting the nav container, which you can see from the grey nav background being present around the dropdown.

We can start to fix this by adding position: absolute to the .dropdown__menu which takes it out of normal document flow:

menu with position absolute

You can see it's aligned to the left and below of the parent list item. Depending on your design, this may be the desirable location.

We're going to pull out a centering trick to align the menu central to the list item:

.dropdown__menu {
  // ... existing styles

  position: absolute;

  // Pull up to overlap the parent list item very slightly
  top: calc(100% - 0.25rem);
  // Use the left from absolute position to shift the left side
  left: 50%;
  // Use translateX to shift the menu 50% of it's width back to the left 
  transform: translateX(-50%);
}

The magic of this centering technique is that the menu could be any width or even a dynamic width and it would center appropriately.

centered dropdown__menu styles

Dropdown Reveal Styles

There are two primary triggers we want used to open the menu: :hover and :focus.

However, traditional :focus will not persist the open state of the dropdown. Once the initial trigger loses focus, the keyboard focus may still move through the dropdown menu, but visually the menu would disappear.

:focus-within

There is an upcoming pseudo-class called :focus-within and it is precisely what we need to make it possible for this to be a CSS-only dropdown. As mentioned in the intro, it does require a polyfill if you need to support IE < Edge 79 (you do... for now).

From MDN, italics mine to show the part we're going to benefit from:

The :focus-within CSS pseudo-class represents an element that has received focus or contains an element that has received focus. In other words, it represents an element that is itself matched by the :focus pseudo-class or has a descendant that is matched by :focus.

Hide the dropdown by default

Before we can reveal the dropdown, we need to hide it, so we will use the hidden styles as the default state.

Your first instinct may be display: none but that locks us out of gracefully animating the transition.

Next, you might try simply opacity: 0 which visibly hides it but leaves behind "ghost links" because the element still has computed height.

Instead, we will use a combination of opacity and transform:

.dropdown__menu {
  // ... existing styles
  transform: rotateX(-90deg) translateX(-50%);
  transform-origin: top center;
  opacity: 0.3;
}

We add opacity but not all the way to 0 to enable a bit smoother effect later.

And, we update our transform property to include rotateX(-90deg), which will rotate the menu in 3D space to 90 degrees "backwards". This effectively removes the height and will make for an interesting transition on reveal. Also you'll notice the transform-origin property which we add to update the point around which the transform is applied, versus the default of the horizontal and vertical center.

Before we do the reveal, we need to add a transition property. We add it to the main .dropdown__menu rule so that it applies both on and off focus/hover, aka "forwards" and "backwards".

.dropdown__menu {
  // ... existing styles
  transition: 280ms all ease-out;
}

Revealing the dropdown

With all that prior setup, revealing the dropdown on both hover and focus can be accomplished as succinctly as:

.dropdown {
  // ... existing styles

  &:hover,
  &:focus-within {

    .dropdown__menu {
      opacity: 1;
      transform: rotateX(0) translateX(-50%);
    } 

  }

}

Essentially, we've reversed the rotateX be resetting to 0, and then bring the opacity all the way up to 1 for full visibility.

Here's the result:

demo of reveal on focus and hover

The rotateX property allows the appearance of the menu swinging in from the back, and opacity just makes it a little softer transition overall.

Handling Hover Intent

If you've been at this web thing for awhile, I'm hoping the following will make you go ๐Ÿคฏ

When I first began battling dropdown menus I was creating them primarily for IE7. On a big project, several team members asked something along the lines of "can you stop the menu appearing if I'm just scrolling/mousing over the menu?". The solution I finally found after much Googling (including trying to come up with the right phrase to get what I was after) was the hoverIntent jQuery plugin.

I needed to set that up because since we are using the transition property, we can also add a very slight delay. For general purposes, this will prevent the dropdown animation triggering for "drive-by" mouseovers.

Order matters, so the delay is added as the third value after designating which properties to transition and before the transition timing function:

.dropdown__menu {
  // ... existing styles
  transition: 280ms all 120ms ease-out;
}

Check out the results:

demo of transition delay with mouseover

It takes a pretty leisurely rollover to trigger the menu, which we can loosely infer as intent to open the menu. The delay is still short enough to not be consciously noticed prior to opening the menu, so it's a win!

You may still choose to use javascript to enhance this particularly if it's going to launch a "mega menu" that would be more disruptive, but this is still pretty delightful.

Dropdown Menu Indicator

Hover intent is one thing, but really we need an additional cue to the user that this menu has additional options. An extremely common convention is a "caret" or "down arrow" mimicking the indicator of a native select element.

To add this, we will update the .dropdown__title styles. We'll define it as an inline-flex container and then create an :after element that uses the border trick to create a downward arrow. We use a dash of translateY() to optically align it with our text:

.dropdown {
  // ... existing styles

    .dropdown__title {
      display: inline-flex;
      align-items: center;
      pointer-events: none;

      &:after {
        content: "";
        border: 0.35rem solid transparent;
        border-top-color: rgba(blue, 0.45);
        margin-left: 0.25em;
        transform: translateY(0.15em);
      }
    }

}

We also snuck in pointer-events: none which will remove any cursor events on the title itself.

dropdown caret indicator

Handling for Touch Devices

If you try out what we've produced so far on mobile, you will not be able to open the menu.

Since there is no :click or :touch pseudo-class in CSS, we need to make the .dropdown__title able to receive focus.

We could turn it into a button since that is a natively focusable element, but we can also apply tabindex="-1" to the span. This also indicates to the browser that the element is able to receive focus, which allows our :focus-within selector to work ๐Ÿ™Œ

Here's the update:

<span tabindex="-1" class="dropdown__title" id="dropdown-title">Sweets</span>

Closing the menu on mobile

Here's where ultimately you may have to enhance with javascript.

To keep it CSS-only, and acceptable for non-application websites, you need to apply tabindex="-1" on the body, effectively allowing any clicks outside of the menu to remove focus from it and allowing it to close.

This is a bit of a stretch - and it may be a little frustrating to users - so you may want to enhance this to hide on scroll as well with javascript especially if you define the nav to use position: sticky and scroll with the user.

Final Result

Here's the final result with a bit of extra styling including an arrow to more visually connect the menu to the link item, custom focus states on all the nav links, and position: sticky on the nav:

Modern CSS Solutions to Old CSS Problems (20 Part Series)

1) Keep the Footer at the Bottom: Flexbox vs. Grid 2) Equal Height Elements: Flexbox vs. Grid 3 ... 18 3) CSS-Only Full-Width Responsive Images 2 Ways 4) Pure CSS Smooth-Scroll "Back to Top" 5) Totally Custom List Styles 6) Animated Image Gallery Captions with Bonus Ken Burns Effect 7) CSS-Only Accessible Dropdown Navigation Menu 8) โœจ Announcing ModernCSS.dev 9) Solutions to Replace the 12-Column Grid 10) CSS Button Styling Guide 11) Icon Button CSS Styling Guide 12) Resource: The Complete Guide to Centering in CSS 13) Generating `font-size` CSS Rules and Creating a Fluid Type Scale 14) Container Query Solutions with CSS Grid and Flexbox 15) Expanded Use of `box-shadow` and `border-radius` 16) 3 CSS Grid Techniques to Make You a Grid Convert 17) 3 Popular Website Heroes Created With CSS Grid Layout 18) Announcing Style Stage: A Community CSS Showcase 19) Pure CSS Custom Styled Radio Buttons 20) Pure CSS Custom Checkbox Style

Posted on by:

5t3ph profile

Stephanie Eckles

@5t3ph

(she/her) โœ๏ธ ModernCSS.dev, ๐Ÿ‘ฉ๐Ÿผโ€๐ŸŽจ StyleStage.dev, ๐Ÿ‘ฉโ€๐Ÿ’ป Lead design system dev, ๐Ÿ‘ฉโ€๐Ÿซ @eggheadio instructor, ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง mom

Discussion

markdown guide
 

I love your post โ€” thank you! I really think everyone should be designing with accessibility in mind.
I listened to Chance Strickland's talk "Thar Be Dragons: Rebuilding Native UIs on the Web" at this year's MagnoliaJS conference and he spoke exactly about rebuilding dropdowns and how to make them accessible. One of the features he mentioned was that when using keyboard navigation, you should be able to:

  • use arrows to select the item from the dropdown;
  • type the letters (in case the dropdown is long) and it should only feature the items you need (e.g. "pa" should give me the options say "pastry" and "paprika")

These features are not available on your page โ€” as soon as I select the dropdown with the keyboard, if I want to use keyboard navigation, the whole page scrolls down unless I use "tab" but that's counterintuitive. Would it be possible to include the arrows or dynamic typing?

 

Thanks! I think this may be two different types of dropdown intent - my example here is intended for lists of links which is a pretty anticipated pattern using semantic HTML, whereas you may be referring to rebuilding form dropdowns perhaps in JS in which case what you've noted is absolutely correct. For navigating links on a page, tabbing is expected as the keyboard interaction. This is also navigable via VoiceOver in expected ways - announced as a nav landmark, and the example noted where the list items refers to the list title via aria-labelledby. An important distinction, thanks for bringing it up!

 

Ah, true! Thank you for your answer and your post. I just saw your website โ€” it's amazing! The styling, the content!

Thank you so much! ๐Ÿ’ซ

 

Wow Stephanie! This is so awesome I don't even know where to start. Way to go! ๐Ÿ‘I love the hit of nostalgia this gave me too with the bits about IE7 (* shudders involuntarily *) and hoverIntent!

Keep up the great work.

 

You are too kind! ๐Ÿ˜Š It was fun to make, probably the best example so far of how much CSS has improved over the years. I'm glad someone out there can share my IE7 pain ๐Ÿคฃ

 

Oh wow this is awesome. Recently needing to build an accessible, super lightweight drop down this is really handy! ๐Ÿ˜„ Will make sure to checkout the rest of your series too!

 

Excellent, and thanks for your support! ๐ŸŒŸ

 

As Always Steph, excellent work here thank you for the deep dives.

 

Thanks for your support, John! It's great to know folks find them useful ๐Ÿ˜Š

 

You're #1 in my book of CSS contacts!

 
 

Thanks so much for your job!!!

 

I loved that hover intent trick! Will definitely keep it in mind

 

I re-sensitized myself to it now when I visit other sites that don't consider it ๐Ÿคฃ

 

I LOVE this post. Thank you! It's great to find developers who put accessibility first. I used this to create a language switcher for my site and it works like a charm.

 

Thanks for the feedback and Iโ€™m so glad it worked for you ๐Ÿ™Œ