Global UI: Header and Navigation Intermediate 12 min read

Mobile Nav Patterns: Drawers and Accordions

Build touch-friendly mobile navigation with off-canvas drawers, accordion submenus, and proper focus management for accessibility.

Mobile navigation requires patterns optimized for touch interfaces and smaller screens. Drawer menus and accordions provide familiar, thumb-friendly navigation experiences.

The Hamburger Button

Start with a proper toggle button:

<button
class="menu-toggle"
aria-label="Open menu"
aria-expanded="false"
aria-controls="mobile-menu"
data-menu-toggle
>
<span class="menu-toggle__bar"></span>
<span class="menu-toggle__bar"></span>
<span class="menu-toggle__bar"></span>
</button>
.menu-toggle {
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
width: 44px;
height: 44px;
padding: 10px;
background: none;
border: none;
cursor: pointer;
}
.menu-toggle__bar {
display: block;
width: 24px;
height: 2px;
background: currentColor;
transition:
transform 0.3s,
opacity 0.3s;
}
/* Animate to X when open */
.menu-toggle[aria-expanded='true'] .menu-toggle__bar:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.menu-toggle[aria-expanded='true'] .menu-toggle__bar:nth-child(2) {
opacity: 0;
}
.menu-toggle[aria-expanded='true'] .menu-toggle__bar:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}

Off-Canvas Drawer

Drawer Markup

{# Mobile menu drawer #}
<div
class="mobile-menu"
id="mobile-menu"
aria-hidden="true"
data-mobile-menu
>
<div class="mobile-menu__overlay" data-menu-close></div>
<nav class="mobile-menu__drawer" aria-label="Mobile navigation">
<div class="mobile-menu__header">
<span class="mobile-menu__title">Menu</span>
<button
class="mobile-menu__close"
aria-label="Close menu"
data-menu-close
>
{% render 'icon-close' %}
</button>
</div>
<div class="mobile-menu__content">
<ul class="mobile-nav">
{%- for link in linklists[section.settings.menu].links -%}
<li class="mobile-nav__item">
{%- if link.links.size > 0 -%}
<details class="mobile-nav__details">
<summary class="mobile-nav__link mobile-nav__link--parent">
<span>{{ link.title }}</span>
<span class="mobile-nav__icon">
{% render 'icon-chevron-down' %}
</span>
</summary>
<ul class="mobile-nav__submenu">
<li>
<a href="{{ link.url }}" class="mobile-nav__sublink">
View All {{ link.title }}
</a>
</li>
{%- for child in link.links -%}
<li>
<a href="{{ child.url }}" class="mobile-nav__sublink">
{{ child.title }}
</a>
</li>
{%- endfor -%}
</ul>
</details>
{%- else -%}
<a href="{{ link.url }}" class="mobile-nav__link">
{{ link.title }}
</a>
{%- endif -%}
</li>
{%- endfor -%}
</ul>
</div>
<div class="mobile-menu__footer">
<a href="{{ routes.account_url }}" class="mobile-menu__account">
{% render 'icon-account' %}
<span>Account</span>
</a>
</div>
</nav>
</div>

Drawer Styles

.mobile-menu {
position: fixed;
inset: 0;
z-index: 1000;
visibility: hidden;
pointer-events: none;
}
.mobile-menu.is-open {
visibility: visible;
pointer-events: auto;
}
/* Overlay */
.mobile-menu__overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
opacity: 0;
transition: opacity 0.3s;
}
.mobile-menu.is-open .mobile-menu__overlay {
opacity: 1;
}
/* Drawer panel */
.mobile-menu__drawer {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: min(320px, 85vw);
display: flex;
flex-direction: column;
background: var(--color-background);
transform: translateX(-100%);
transition: transform 0.3s ease-out;
}
.mobile-menu.is-open .mobile-menu__drawer {
transform: translateX(0);
}
/* Right-side drawer variant */
.mobile-menu--right .mobile-menu__drawer {
left: auto;
right: 0;
transform: translateX(100%);
}
.mobile-menu--right.is-open .mobile-menu__drawer {
transform: translateX(0);
}
/* Header */
.mobile-menu__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.mobile-menu__title {
font-weight: 600;
}
.mobile-menu__close {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: none;
border: none;
cursor: pointer;
}
/* Content */
.mobile-menu__content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
}
/* Footer */
.mobile-menu__footer {
padding: var(--spacing-md);
border-top: 1px solid var(--color-border);
}
.mobile-menu__account {
display: flex;
align-items: center;
gap: var(--spacing-sm);
color: inherit;
text-decoration: none;
}

Accordion Submenus

/* Navigation list */
.mobile-nav {
list-style: none;
padding: 0;
margin: 0;
}
.mobile-nav__item {
border-bottom: 1px solid var(--color-border);
}
.mobile-nav__item:last-child {
border-bottom: none;
}
/* Links */
.mobile-nav__link {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) 0;
color: inherit;
text-decoration: none;
font-size: 1.125rem;
}
/* Details/summary accordion */
.mobile-nav__details {
width: 100%;
}
.mobile-nav__details summary {
list-style: none;
cursor: pointer;
}
.mobile-nav__details summary::-webkit-details-marker {
display: none;
}
.mobile-nav__icon {
display: flex;
transition: transform 0.2s;
}
.mobile-nav__icon svg {
width: 20px;
height: 20px;
}
.mobile-nav__details[open] .mobile-nav__icon {
transform: rotate(180deg);
}
/* Submenu */
.mobile-nav__submenu {
list-style: none;
padding: 0 0 var(--spacing-md) var(--spacing-md);
margin: 0;
}
.mobile-nav__sublink {
display: block;
padding: var(--spacing-sm) 0;
color: var(--color-text-light);
text-decoration: none;
}
.mobile-nav__sublink:hover,
.mobile-nav__sublink:focus {
color: var(--color-text);
}

JavaScript for Drawer

class MobileMenu extends HTMLElement {
constructor() {
super();
this.menu = this;
this.drawer = this.querySelector('.mobile-menu__drawer');
this.focusableElements = null;
this.firstFocusable = null;
this.lastFocusable = null;
}
connectedCallback() {
// Toggle button
document.querySelectorAll('[data-menu-toggle]').forEach((btn) => {
btn.addEventListener('click', () => this.toggle());
});
// Close buttons
this.querySelectorAll('[data-menu-close]').forEach((btn) => {
btn.addEventListener('click', () => this.close());
});
// Close on escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen) {
this.close();
}
});
}
get isOpen() {
return this.classList.contains('is-open');
}
toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
open() {
this.classList.add('is-open');
this.setAttribute('aria-hidden', 'false');
document.body.classList.add('menu-open');
// Update toggle button
document.querySelectorAll('[data-menu-toggle]').forEach((btn) => {
btn.setAttribute('aria-expanded', 'true');
});
// Prevent background scroll
document.body.style.overflow = 'hidden';
// Focus management
this.setupFocusTrap();
this.firstFocusable?.focus();
}
close() {
this.classList.remove('is-open');
this.setAttribute('aria-hidden', 'true');
document.body.classList.remove('menu-open');
// Update toggle button
const toggleBtn = document.querySelector('[data-menu-toggle]');
toggleBtn?.setAttribute('aria-expanded', 'false');
toggleBtn?.focus();
// Restore scroll
document.body.style.overflow = '';
}
setupFocusTrap() {
this.focusableElements = this.drawer.querySelectorAll(
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
this.firstFocusable = this.focusableElements[0];
this.lastFocusable = this.focusableElements[this.focusableElements.length - 1];
this.drawer.addEventListener('keydown', this.trapFocus.bind(this));
}
trapFocus(event) {
if (event.key !== 'Tab') return;
if (event.shiftKey) {
if (document.activeElement === this.firstFocusable) {
event.preventDefault();
this.lastFocusable.focus();
}
} else {
if (document.activeElement === this.lastFocusable) {
event.preventDefault();
this.firstFocusable.focus();
}
}
}
}
customElements.define('mobile-menu', MobileMenu);

Touch-Friendly Tap Targets

Ensure adequate touch target sizes:

/* Minimum 44x44px tap targets */
.mobile-nav__link,
.mobile-nav__sublink {
min-height: 44px;
display: flex;
align-items: center;
}
.mobile-menu__close,
.menu-toggle {
min-width: 44px;
min-height: 44px;
}
/* Add padding for smaller text */
.mobile-nav__sublink {
padding: var(--spacing-sm) var(--spacing-md);
margin: 0 calc(var(--spacing-md) * -1);
}

Swipe to Close

Add gesture support:

class SwipeableDrawer extends HTMLElement {
constructor() {
super();
this.drawer = this.querySelector('.mobile-menu__drawer');
this.startX = 0;
this.currentX = 0;
this.threshold = 100;
}
connectedCallback() {
this.drawer.addEventListener('touchstart', this.onTouchStart.bind(this), { passive: true });
this.drawer.addEventListener('touchmove', this.onTouchMove.bind(this), { passive: false });
this.drawer.addEventListener('touchend', this.onTouchEnd.bind(this), { passive: true });
}
onTouchStart(e) {
this.startX = e.touches[0].clientX;
}
onTouchMove(e) {
if (!this.startX) return;
this.currentX = e.touches[0].clientX;
const diff = this.startX - this.currentX;
// Only allow swiping in the close direction (left for left drawer)
if (diff > 0) {
e.preventDefault();
this.drawer.style.transform = `translateX(${-diff}px)`;
}
}
onTouchEnd() {
const diff = this.startX - this.currentX;
if (diff > this.threshold) {
this.close();
} else {
// Snap back
this.drawer.style.transform = '';
}
this.startX = 0;
this.currentX = 0;
}
}

Multi-Level Navigation

For three levels of nesting:

<ul class="mobile-nav">
{%- for link in menu.links -%}
<li class="mobile-nav__item">
{%- if link.links.size > 0 -%}
<details class="mobile-nav__details">
<summary class="mobile-nav__link">
{{ link.title }}
<span class="mobile-nav__icon">{% render 'icon-chevron-down' %}</span>
</summary>
<ul class="mobile-nav__submenu">
<li>
<a href="{{ link.url }}" class="mobile-nav__sublink mobile-nav__sublink--all">
View All
</a>
</li>
{%- for child in link.links -%}
<li>
{%- if child.links.size > 0 -%}
<details class="mobile-nav__details mobile-nav__details--nested">
<summary class="mobile-nav__sublink">
{{ child.title }}
<span class="mobile-nav__icon">{% render 'icon-chevron-down' %}</span>
</summary>
<ul class="mobile-nav__submenu mobile-nav__submenu--level-3">
{%- for grandchild in child.links -%}
<li>
<a href="{{ grandchild.url }}" class="mobile-nav__sublink">
{{ grandchild.title }}
</a>
</li>
{%- endfor -%}
</ul>
</details>
{%- else -%}
<a href="{{ child.url }}" class="mobile-nav__sublink">
{{ child.title }}
</a>
{%- endif -%}
</li>
{%- endfor -%}
</ul>
</details>
{%- else -%}
<a href="{{ link.url }}" class="mobile-nav__link">{{ link.title }}</a>
{%- endif -%}
</li>
{%- endfor -%}
</ul>
.mobile-nav__submenu--level-3 {
padding-left: var(--spacing-lg);
border-left: 2px solid var(--color-border);
margin-left: var(--spacing-md);
}

Complete Mobile Menu Component

{# sections/mobile-menu.liquid #}
<mobile-menu
class="mobile-menu"
id="mobile-menu"
aria-hidden="true"
>
<div class="mobile-menu__overlay" data-menu-close></div>
<nav class="mobile-menu__drawer" aria-label="Mobile navigation">
<div class="mobile-menu__header">
{%- if section.settings.logo -%}
<img
src="{{ section.settings.logo | image_url: width: 150 }}"
alt="{{ shop.name }}"
class="mobile-menu__logo"
>
{%- else -%}
<span class="mobile-menu__title">{{ shop.name }}</span>
{%- endif -%}
<button class="mobile-menu__close" aria-label="Close menu" data-menu-close>
{% render 'icon-close' %}
</button>
</div>
<div class="mobile-menu__content">
{%- assign menu = linklists[section.settings.menu] -%}
{%- if menu.links.size > 0 -%}
<ul class="mobile-nav">
{%- for link in menu.links -%}
<li class="mobile-nav__item">
{% render 'mobile-nav-item', link: link %}
</li>
{%- endfor -%}
</ul>
{%- endif -%}
</div>
<div class="mobile-menu__footer">
{%- if section.settings.show_account -%}
<a href="{{ routes.account_url }}" class="mobile-menu__footer-link">
{% render 'icon-account' %}
<span>{% if customer %}My Account{% else %}Log In{% endif %}</span>
</a>
{%- endif -%}
{%- if section.settings.show_search -%}
<a href="{{ routes.search_url }}" class="mobile-menu__footer-link">
{% render 'icon-search' %}
<span>Search</span>
</a>
{%- endif -%}
</div>
</nav>
</mobile-menu>
{% schema %}
{
"name": "Mobile Menu",
"settings": [
{
"type": "image_picker",
"id": "logo",
"label": "Logo"
},
{
"type": "link_list",
"id": "menu",
"label": "Menu",
"default": "main-menu"
},
{
"type": "checkbox",
"id": "show_account",
"label": "Show account link",
"default": true
},
{
"type": "checkbox",
"id": "show_search",
"label": "Show search link",
"default": true
}
]
}
{% endschema %}

Practice Exercise

Build a mobile menu that:

  1. Opens from the left with a slide animation
  2. Has an overlay that closes the menu on click
  3. Uses accordions for nested navigation
  4. Traps focus within the drawer
  5. Closes on Escape key

Test your implementation:

  • Does the menu animate smoothly?
  • Can you navigate with keyboard only?
  • Does focus return to the toggle button on close?
  • Is the background scroll locked when open?

Key Takeaways

  1. Use a hamburger button with proper ARIA attributes
  2. Animate the drawer with CSS transforms for smooth performance
  3. Add an overlay that closes on click
  4. Implement accordions with <details> for submenus
  5. Trap focus within the open drawer
  6. Lock body scroll when menu is open
  7. Ensure 44px tap targets minimum
  8. Support gestures like swipe to close
  9. Return focus to toggle on close

What’s Next?

The navigation system is complete. The final lesson covers Sticky Header and Announcements for enhanced header functionality.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...