Markup first: the skeleton

Open app/index.html after selecting Remix to Edit, then drop this into <body>:

<aside></aside>
<main></main>

<aside> carries the navigation menu as a companion to <main>, which carries the page content.

Now fill those two elements in. Inside <aside>, add a <nav> with navigation links and a closing link:

<aside>
  <nav>
    <h4>My</h4>
    <a href="#">Dashboard</a>
    <a href="#">Profile</a>
    <a href="#">Preferences</a>
    <a href="#">Archive</a>

    <h4>Settings</h4>
    <a href="#">Accessibility</a>
    <a href="#">Theme</a>
    <a href="#">Admin</a>
  </nav>

  <a href="#"></a>
</aside>

Inside <main>, add a header and an article:

<main>
  <header>
    <a href="#sidenav-open" class="hamburger">
      <svg viewBox="0 0 50 40">
        <line x1="0" x2="100%" y1="10%" y2="10%" />
        <line x1="0" x2="100%" y1="50%" y2="50%" />
        <line x1="0" x2="100%" y1="90%" y2="90%" />
      </svg>
    </a>
    <h1>Site Title</h1>
  </header>

  <article>
    {put some placeholder content here}
  </article>
</main>

The header holds the menu open link, the aside holds the close button; which one is shown depends on viewport size. The placeholder sentence in the <article> can be replaced with your own copy or the supplied lorem:

<h2>Totam Header</h2>
<p>Lorem ipsum dolor, sit amet consectetur adipisicing elit. Cum consectetur, necessitatibus velit officia ut impedit veritatis temporibus soluta? Totam odit cupiditate facilis nisi sunt hic necessitatibus voluptatem nihil doloribus! Enim.</p>
<p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Fugit rerum, amet odio explicabo voluptas eos cum libero, ex esse quasi optio incidunt soluta eligendi labore error corrupti! Dolore, cupiditate porro.</p>

<h3>Subhead Totam Odit</h3>
<p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Fugit rerum, amet odio explicabo voluptas eos cum libero, ex esse quasi optio incidunt soluta eligendi labore error corrupti! Dolore, cupiditate porro.</p>
<p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Fugit rerum, amet odio explicabo voluptas eos cum libero, ex esse quasi optio incidunt soluta eligendi labore error corrupti! Dolore, cupiditate porro.</p>

<h3>Subhead</h3>
<p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Fugit rerum, amet odio explicabo voluptas eos cum libero, ex esse quasi optio incidunt soluta eligendi labore error corrupti! Dolore, cupiditate porro.</p>
<p>Lorem ipsum dolor sit, amet consectetur adipisicing elit. Fugit rerum, amet odio explicabo voluptas eos cum libero, ex esse quasi optio incidunt soluta eligendi labore error corrupti! Dolore, cupiditate porro.</p>

Article length is what makes the page scroll once it passes the viewport height.

Making the actions unambiguous

The open link needs clearer labelling. Add title and aria-label:

<a href="#sidenav-open" class="hamburger">
<a href="#sidenav-open" title="Open Menu" aria-label="Open Menu" class="hamburger">

Label the open SVG icon inside that link too:

<svg viewBox="0 0 50 40">
<svg viewBox="0 0 50 40" role="presentation" focusable="false" aria-label="trigram for heaven symbol">

Do the same for the close link in the sidenav:

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

The body layout and the URL as state

Both the main content and the sidenav are direct children of <body>, so start there. Add this to css/sidenav.css:

body {
  display: grid;
  grid: [stack] 1fr / min-content [stack] 1fr;

  @media (max-width: 540px) {
    & > :matches(aside, main) {
      grid-area: stack;
    }
  }
}
>

Read as a whole: a named row stack holding two columns, the second of which is also named stack. Column one is sized by its minimum content; column two takes the remainder. At 540px or below, both children land in the same row and column, stacking into a 1x1 grid.

With responsive stacking in place, the URL bar can drive the sidenav's visibility and transitions. First give the <aside> in app/index.html its ID:

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

That pairing of element ID and URL hash is what makes :target work. Add IDs to the sidenav open and close links as well, so JavaScript has clean hooks:

<a href="#sidenav-open" class="hamburger" title="Open Menu" aria-label="Open Menu">
<a href="#sidenav-open" id="sidenav-button" class="hamburger" title="Open Menu" aria-label="Open Menu">
>
<a href="#" title="Close Menu" aria-label="Close Menu"></a>
<a href="#" id="sidenav-close" title="Close Menu" aria-label="Close Menu"></a>
>

Styling the aside

The <aside> holds two children: the <nav> that slides out, and a closing <a> that points the URL hash at #. That link sits invisibly to the right of the panel so a click beside the component dismisses it.

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

Controlling the ratio and naming the grid areas gives a designer a lot of latitude here.

Main content still has to be overlaid conditionally and scroll position preserved — work for position: sticky plus overscroll-behavior:

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

  @media (max-width: 540px) {
    position: sticky;
    top: 0;
    max-height: 100vh;
    overflow: hidden auto;
    overscroll-behavior: contain;

    visibility: hidden; /* not keyboard accessible when closed */
  }
}
>

Those rules give the sidenav viewport height, vertical scrolling and scroll containment, and hide the element. It stays hidden at 540px and below — unless the #sidenav-open element carries :target:

#sidenav-open {

  @media (max-width: 540px) {

    &:target {
      visibility: visible;
    }
  }
}
>

When hash and ID match, visibility becomes visible. Try opening the menu after scrolling, or scrolling while it is open.

Next, the buttons and the transitions:

#sidenav-button,
#sidenav-close {
  -webkit-tap-highlight-color: transparent;
  -webkit-touch-callout: none;
  user-select: none;
  touch-action: manipulation;

  @media (min-width: 540px) {
    display: none;
  }
}
>
#sidenav-open {
  --easeOutExpo: cubic-bezier(0.16, 1, 0.3, 1);
  --duration: .6s;

  ...

  @media (max-width: 540px) {
    ...

    transform: translateX(-110vw);
    will-change: transform;
    transition:
      transform var(--duration) var(--easeOutExpo),
      visibility 0s linear var(--duration);

    &:target {
      visibility: visible;
      transform: translateX(0);
      transition: transform var(--duration) var(--easeOutExpo);
    }
  }

  @media (prefers-reduced-motion: reduce) {
    --duration: 1ms;
  }
}
>
A demo of the interaction with and without duration applied based on the `prefers-reduced-motion` media query.

The first block covers tap and touch styling for the open and close buttons and hides them at 540px and up; the second adds transforms with accessibility respected.

Keyboard and focus behaviour

Escape should dismiss the menu, so add this to js/index.js:

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

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

The listener watches key events on the sidenav element and, on Escape, empties the URL hash so the sidenav transitions away.

Focus management comes next: after a sidenav transition settles, compare it against the URL hash to tell whether it opened or closed, then move focus to the button complementary to the one just pressed.

const closenav = document.querySelector('#sidenav-close');
const opennav = document.querySelector('#sidenav-button');

sidenav.addEventListener('transitionend', e => {
  if (e.propertyName !== 'transform') {
    return;
  }

  const isOpen = document.location.hash === '#sidenav-open';

  isOpen
    ? closenav.focus()
    : opennav.focus();
});
>

Checking the result

  • Press View App to preview, then Fullscreen fullscreen.

Where to take it

The component covers the needs it was built for, but it's a starting point — drive it from JavaScript state rather than the URL, or reshape it entirely.

css/brandnav.css holds the non-layout styling kept separate from this feature set on purpose, so the layout styles stay copy-pasteable. Slide-out sidenavs vary a lot between projects — including setups with more than one, such as a panel on each side.