Building a sidenav component

A foundational overview of how to build a responsive slide out sidenav

In this post I want to share with you how I prototyped a Sidenav component for the web that is responsive, stateful, supports keyboard navigation, works with and without JavaScript, and works across browsers. Try the demo.

If you prefer video, here's a YouTube version of this post:

Overview

It's tough building a responsive navigation system. Some users will be on a keyboard, some will have powerful desktops, and some will visit from a small mobile device. Everyone visiting should be able to open and close the menu.

Desktop to mobile responsive layout demo
Light and dark theme down on iOS and Android

Web Tactics

In this component exploration I had the joy of combining a few critical web platform features:

  1. CSS :target
  2. CSS grid
  3. CSS transforms
  4. CSS Media Queries for viewport and user preference
  5. JS for focus UX enhancements

My solution has one sidebar and toggles only when at a "mobile" viewport of 540px or less. 540px will be our breakpoint for switching between the mobile interactive layout and the static desktop layout.

CSS :target pseudo-class

One <a> link sets the url hash to #sidenav-open and the other to empty (''). Lastly, an element has the id to match the hash:

<a href="#sidenav-open" id="sidenav-button" title="Open Menu" aria-label="Open Menu">

<a href="#" id="sidenav-close" title="Close Menu" aria-label="Close Menu"></a>

<aside id="sidenav-open">
  …
</aside>

Clicking each of these links changes the hash state of our page URL, then with a pseudo-class I show and hide the sidenav:

@media (max-width: 540px) {
  #sidenav-open {
    visibility: hidden;
  }

  #sidenav-open:target {
    visibility: visible;
  }
}

CSS Grid

In the past, I only used absolute or fixed position sidenav layouts and components. Grid though, with its grid-area syntax, lets us assign multiple elements to the same row or column.

Stacks

The primary layout element #sidenav-container is a grid that creates 1 row and 2 columns, 1 of each are named stack. When space is constrained, CSS assigns all of the <main> element's children to the same grid name, placing all elements into the same space, creating a stack.

#sidenav-container {
  display: grid;
  grid: [stack] 1fr / min-content [stack] 1fr;
  min-height: 100vh;
}

@media (max-width: 540px) {
  #sidenav-container > * {
    grid-area: stack;
  }
}

The <aside> is the animating element that contains the side navigation. It has 2 children: the navigation container <nav> named [nav] and a backdrop <a> named [escape], which is used to close the menu.

#sidenav-open {
  display: grid;
  grid-template-columns: [nav] 2fr [escape] 1fr;
}

Adjust 2fr & 1fr to find the ratio you like for the menu overlay and its negative space close button.

A demo of what happens when you change the ratio.

CSS 3D transforms & transitions

Our layout is now stacked at a mobile viewport size. Until I add some new styles, it's overlaying our article by default. Here's some UX I'm shooting for in this next section:

  • Animate open and close
  • Only animate with motion if the user is OK with that
  • Animate visibility so keyboard focus doesn't enter the offscreen element

As I begin to implement motion animations, I want to start with accessibility top of mind.

Accessible motion

Not everyone will want a slide out motion experience. In our solution this preference is applied by adjusting a --duration CSS variable inside a media query. This media query value represents a user's operating system preference for motion (if available).

#sidenav-open {
  --duration: .6s;
}

@media (prefers-reduced-motion: reduce) {
  #sidenav-open {
    --duration: 1ms;
  }
}
A demo of the interaction with and without duration applied.

Now when our sidenav is sliding open and closed, if a user prefers reduced motion, I instantly move the element into view, maintaining state without motion.

Transition, transform, translate

Sidenav out (default)

To set the default state of our sidenav on mobile to an offscreen state, I position the element with transform: translateX(-110vw).

Note, I added another 10vw to the typical offscreen code of -100vw, to ensure the box-shadow of the sidenav doesn't peek into the main viewport when it's hidden.

@media (max-width: 540px) {
  #sidenav-open {
    visibility: hidden;
    transform: translateX(-110vw);
    will-change: transform;
    transition:
      transform var(--duration) var(--easeOutExpo),
      visibility 0s linear var(--duration);
  }
}
Sidenav in

When the #sidenav element matches as :target, set the translateX() position to homebase 0, and watch as CSS slides the element from its out position of -110vw, to its "in" position of 0 over var(--duration) when the URL hash is changed.

@media (max-width: 540px) {
  #sidenav-open:target {
    visibility: visible;
    transform: translateX(0);
    transition:
      transform var(--duration) var(--easeOutExpo);
  }
}

Transition visibility

The goal now is to hide the menu from screenreaders when it's out, so systems don't put focus into an offscreen menu. I accomplish this by setting a visibility transition when the :target changes.

  • When going in, don't transition visibility; be visible right away so I can see the element slide in and accept focus.
  • When going out, transition visibility but delay it, so it flips to hidden at the end of the transition out.

Accessibility UX enhancements

This solution relies on changing the URL in order for the state to be managed. Naturally, the <a> element should be used here, and it gets some nice accessibility features for free. Let's adorn our interactive elements with labels clearly articulating intent.

<a href="#" id="sidenav-close" title="Close Menu" aria-label="Close Menu"></a>

<a href="#sidenav-open" id="sidenav-button" class="hamburger" title="Open Menu" aria-label="Open Menu">
  <svg>...</svg>
</a>
A demo of the voiceover and keyboard interaction UX.

Now our primary interaction buttons clearly state their intent for both mouse and keyboard.

:is(:hover, :focus)

This handy CSS functional pseudo-selector lets us swiftly be inclusive with our hover styles by sharing them with focus as well.

.hamburger:is(:hover, :focus) svg > line {
  stroke: hsl(var(--brandHSL));
}

Sprinkle on JavaScript

Press escape to close

The Escape key on your keyboard should close the menu right? Let's wire that up.

const sidenav = document.querySelector('#sidenav-open');

sidenav.addEventListener('keyup', event => {
  if (event.code === 'Escape') document.location.hash = '';
});
Browser history

In order to prevent the open and close interaction from stacking multiple entries into the browser history, add the following JavaScript inline to the close button:

<a href="#" id="sidenav-close" title="Close Menu" aria-label="Close Menu" onchange="history.go(-1)"></a>

This will remove the URL history entry on close, making it as if the menu was never opened.

Focus UX

The next snippet helps us put focus on the open and close buttons after they open or close. I want to make toggling easy.

sidenav.addEventListener('transitionend', e => {
  const isOpen = document.location.hash === '#sidenav-open';

  isOpen
      ? document.querySelector('#sidenav-close').focus()
      : document.querySelector('#sidenav-button').focus();
})

When the sidenav opens, focus the close button. When the sidenav closes, focus the open button. I do this by calling focus() on the element in JavaScript.

Conclusion

Now that you know how I did it, how would you?! This makes for some fun component architecture! Who's going to make the 1st version with slots? 🙂

Let's diversify our approaches and learn all the ways to build on the web. Create a Glitch, tweet me your version, and I'll add it to the Community remixes section below.

Community remixes