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
| Pattern | How it works | Good for | Watch out for |
|---|---|---|---|
| Wrap | Links wrap onto two or more lines | Up to about five short links | Takes vertical space on small screens |
| Disclosure | A "Menu" button shows and hides the list below the header | Most sites | Needs correct button state and focus handling |
| Off-canvas | A panel slides in from the side | Larger menus, apps | Hidden links must leave the tab order; motion preferences |
| Priority plus | The most important links stay visible, the rest go into a "More" menu | Sites where a few links matter most | Needs JavaScript to measure space; extra complexity |
| Bottom bar | A fixed bar of three to five icons with labels | App-like sites | Takes 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
<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 withhref="#"does neither properly. aria-expandedtells assistive technology whether the menu is open.aria-controlspoints 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
.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
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:
- On load,
syncToLayout()shows the button and closes the menu on narrow screens, or hides the button and shows the menu on wide ones. - Clicking the button flips
aria-expandedand thehiddenattribute together, so the visual state and the announced state never disagree. - Escape closes an open menu and returns focus to the button, so keyboard users are not left stranded.
- 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:
.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 anaria-label, and a real<button>for any toggle. - Keep
aria-expandedin sync with what is visible. - Hidden links must not be focusable: use
hidden,display: none,visibility: hiddenorinert. - 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-motionfor 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-expandedthat 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
inertwhen 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.