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:
- Opens from the left with a slide animation
- Has an overlay that closes the menu on click
- Uses accordions for nested navigation
- Traps focus within the drawer
- 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
- Use a hamburger button with proper ARIA attributes
- Animate the drawer with CSS transforms for smooth performance
- Add an overlay that closes on click
- Implement accordions with
<details>for submenus - Trap focus within the open drawer
- Lock body scroll when menu is open
- Ensure 44px tap targets minimum
- Support gestures like swipe to close
- 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...