Responsive navigation patterns

Choose between priority, off-canvas and disclosure menus, and build a mobile menu that works for keyboard and screen reader users.

  • Beginner
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Build an accessible mobile menu
  • Choose a navigation pattern
  • Keep hidden menu items out of the tab order

Before you start

Navigation is often the first thing to break on a small screen. Six links that sit happily in a row on a laptop need somewhere to go on a phone. This guide compares the main patterns and builds an accessible disclosure menu step by step.

Start by reducing the problem

Before choosing a pattern, ask whether the menu really needs to collapse:

  • Fewer, clearer top-level links often fit on every screen. Four short links wrap well with flex-wrap.
  • Secondary links can move to the footer, where people look for them anyway.
  • The wrapping cluster from lesson 7 is a valid responsive menu with no JavaScript at all.

If everything still does not fit, pick one of the patterns below.

The main patterns

PatternHow it worksGood forWatch out for
WrapLinks wrap onto two or more linesUp to about five short linksTakes vertical space on small screens
DisclosureA "Menu" button shows and hides the list below the headerMost sitesNeeds correct button state and focus handling
Off-canvasA panel slides in from the sideLarger menus, appsHidden links must leave the tab order; motion preferences
Priority plusThe most important links stay visible, the rest go into a "More" menuSites where a few links matter mostNeeds JavaScript to measure space; extra complexity
Bottom barA fixed bar of three to five icons with labelsApp-like sitesTakes permanent screen space; poor for many destinations

The disclosure pattern is the simplest that scales, so it is the one to build first.

Building a disclosure menu

The HTML

HTML
<header class="site-header">
  <a class="logo" href="/">Geeks2Smart</a>

  <nav class="site-nav" aria-label="Main">
    <button class="nav-toggle" type="button"
            aria-expanded="false" aria-controls="main-menu" hidden>
      Menu
    </button>

    <ul class="nav-list" id="main-menu">
      <li><a href="/courses">Courses</a></li>
      <li><a href="/tutorials">Tutorials</a></li>
      <li><a href="/guides">Guides</a></li>
      <li><a href="/about">About</a></li>
      <li><a href="/contact" aria-current="page">Contact</a></li>
    </ul>
  </nav>
</header>

Key decisions:

  • <nav aria-label="Main"> is a navigation landmark with a name, so screen reader users can jump to it and tell it apart from other <nav> elements.
  • The toggle is a real <button>. It works with Enter and Space and is announced as a button. A <div> or a link with href="#" does neither properly.
  • aria-expanded tells assistive technology whether the menu is open.
  • aria-controls points to the list the button controls.
  • The button starts hidden, and the list starts visible. If JavaScript fails to load, every link is still shown.
  • aria-current="page" marks the current page for screen readers. Style it so it is visible too.

The CSS

CSS
.site-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem 1rem;
  padding: 0.75rem 1rem;
}

.site-nav {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  flex-grow: 1;
}

.nav-toggle {
  min-height: 2.75rem;
  padding-inline: 1rem;
  font: inherit;
}

.nav-list {
  display: flex;
  flex-direction: column;
  flex-basis: 100%;
  margin: 0;
  padding: 0;
  list-style: none;
}

.nav-list a {
  display: block;
  padding: 0.75rem 0;
}

.nav-list a[aria-current="page"] {
  font-weight: 700;
  text-decoration-thickness: 0.15em;
}

.nav-list[hidden],
.nav-toggle[hidden] {
  display: none;
}

@media (width >= 48rem) {
  .nav-list {
    flex-direction: row;
    flex-basis: auto;
    gap: 1.5rem;
  }
}

Mobile-first again: the list is a vertical stack by default and becomes a row from 48rem.

The hidden attribute works through the browser's default style display: none. Any author rule that sets display, such as .nav-list { display: flex; }, overrides it, and the "hidden" menu stays visible. The [hidden] rules above restore it. Keep them.

The JavaScript

JavaScript
const toggle = document.querySelector('.nav-toggle');
const menu = document.getElementById('main-menu');
const wide = window.matchMedia('(width >= 48rem)');

function setOpen(open) {
  toggle.setAttribute('aria-expanded', String(open));
  menu.hidden = !open;
}

function syncToLayout() {
  if (wide.matches) {
    toggle.hidden = true;
    menu.hidden = false;
  } else {
    toggle.hidden = false;
    setOpen(false);
  }
}

toggle.addEventListener('click', () => {
  const isOpen = toggle.getAttribute('aria-expanded') === 'true';
  setOpen(!isOpen);
});

document.addEventListener('keydown', (event) => {
  const isOpen = toggle.getAttribute('aria-expanded') === 'true';
  if (event.key === 'Escape' && isOpen && !wide.matches) {
    setOpen(false);
    toggle.focus();
  }
});

wide.addEventListener('change', syncToLayout);
syncToLayout();

What it does:

  1. On load, syncToLayout() shows the button and closes the menu on narrow screens, or hides the button and shows the menu on wide ones.
  2. Clicking the button flips aria-expanded and the hidden attribute together, so the visual state and the announced state never disagree.
  3. Escape closes an open menu and returns focus to the button, so keyboard users are not left stranded.
  4. When the viewport crosses 48rem, for example when a tablet rotates or a window is resized, the menu resets to the right state.

The breakpoint in matchMedia() must match the one in the CSS. Keep it in one place, as described in lesson 8.

Because the button label "Menu" stays the same and aria-expanded carries the state, screen readers announce something like "Menu, button, collapsed". Do not also change the label to "Close", or users hear the state twice.

Off-canvas menus

An off-canvas menu slides a panel in over or beside the page. The extra risk is hidden content that keyboard users can still reach. A panel moved off screen with transform is still in the tab order, so a keyboard user tabs through invisible links.

When the panel is closed, remove it from interaction completely:

CSS
.drawer {
  position: fixed;
  inset-block: 0;
  inset-inline-start: 0;
  width: min(20rem, 85vw);
  transform: translateX(-100%);
  visibility: hidden;
  transition: transform 0.3s ease, visibility 0.3s;
}

.drawer.is-open {
  transform: none;
  visibility: visible;
}

@media (prefers-reduced-motion: reduce) {
  .drawer {
    transition: none;
  }
}

visibility: hidden removes the links from the tab order and from the accessibility tree, and the transition on visibility keeps the panel visible until the slide-out finishes. translateX(-100%) moves the panel out to the left in left-to-right pages; for right-to-left pages, use 100% instead.

When the drawer is open and covers the page, the content behind it should not be reachable either. Set the inert attribute on the main content while the drawer is open, and remove it when it closes. For a full modal drawer, the native <dialog> element with showModal() handles this, plus focus and Escape, for you.

Priority plus

Priority plus keeps the most important links visible and moves the rest into a "More" disclosure as space runs out. It needs JavaScript to measure the available width, usually with a ResizeObserver, and to move items between lists. It works well for content-heavy sites, but it is the most complex pattern to get right. Make sure the "More" button follows the same disclosure rules as above, with aria-expanded and Escape support.

A checklist for any pattern

  • Use <nav> with an aria-label, and a real <button> for any toggle.
  • Keep aria-expanded in sync with what is visible.
  • Hidden links must not be focusable: use hidden, display: none, visibility: hidden or inert.
  • Escape closes menus and returns focus to the button that opened them.
  • Touch targets of at least about 44 by 44 CSS pixels are comfortable for most people.
  • Respect prefers-reduced-motion for slide and fade effects.
  • Links must work without JavaScript, even if the layout is plainer.
  • Test at 400 percent zoom: a menu that works on a phone should work there too.

Summary

  • Reduce the menu first; a short list that wraps may need no pattern at all.
  • The disclosure pattern is a good default: a real button with aria-expanded that shows and hides the list.
  • Start with links visible and the button hidden, so the menu works without JavaScript.
  • Use matchMedia() to reset the menu state when the layout changes.
  • Off-canvas panels must leave the tab order when closed, and the page behind them should be inert when open.
  • Priority plus is powerful but complex; use it only when the content needs it.

In the final lesson you will fix full-height layouts on mobile with dynamic viewport units.