Mobile Navigation Drawer
Build an accessible mobile navigation drawer with React. Create smooth slide-in animations, nested accordion menus, and proper focus management.
Mobile navigation requires a different approach than desktop. Instead of hover-based dropdowns, we use a sliding drawer with expandable accordion sections. In this lesson, we’ll build a fully accessible mobile navigation component.
Theme Integration
The mobile navigation is mounted via the header entry file and uses the same menu data:
sections/header.liquid├── <div id="header-menu-toggle"> → MenuToggle (React)└── <script id="menu-data"> → JSON menu data
layout/theme.liquid└── <div id="mobile-menu-root"> └── MobileMenu (React) ← You are here └── MobileMenuItem (accordion)The MobileMenu renders via createPortal to document.body for proper overlay behavior. See Header Architecture for the menu data serialization.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
menuData | JSON script element | linklist.links (serialized in header.liquid) |
menuData.items[].title | From JSON | link.title |
menuData.items[].url | From JSON | link.url |
menuData.items[].active | From JSON | link.active |
menuData.items[].childLinks | From JSON | link.links (nested) |
isOpen | Zustand store (useMobileMenu) | - |
expandedItems | Local state | - |
Note: The
menuDatais parsed from a<script type="application/json" id="menu-data">element rendered bysections/header.liquid. See Menu Data from Liquid for the full Liquid serialization code.
Mobile Navigation Architecture
┌─────────────────────────────────────────────────────────────────────┐│ ┌─────────────────────────────────────────────────────────────┐ ││ │ MOBILE DRAWER [X Close] │ ││ ├─────────────────────────────────────────────────────────────┤ ││ │ │ ││ │ ┌─────────────────────────────────────────────────────┐ │ ││ │ │ Shop [▼] │ │ ││ │ ├─────────────────────────────────────────────────────┤ │ ││ │ │ New Arrivals │ │ ││ │ │ Best Sellers │ │ ││ │ │ Sale │ │ ││ │ └─────────────────────────────────────────────────────┘ │ ││ │ │ ││ │ ┌─────────────────────────────────────────────────────┐ │ ││ │ │ About │ │ ││ │ └─────────────────────────────────────────────────────┘ │ ││ │ │ ││ │ ┌─────────────────────────────────────────────────────┐ │ ││ │ │ Contact │ │ ││ │ └─────────────────────────────────────────────────────┘ │ ││ │ │ ││ └─────────────────────────────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────────┘The Mobile Navigation Component
import { useState, useRef, useEffect } from 'react';import { useMobileMenu } from '@/stores/ui';import { readJsonScript } from '@/utils/data-bridge';import type { MenuData, MenuItem } from '@/types/navigation';import { Drawer } from '@/components/ui';import { MobileNavItem } from './MobileNavItem';import styles from './MobileNav.module.css';
export function MobileNav() { const { isOpen, close } = useMobileMenu(); const menuData = readJsonScript('menu-data') as MenuData | null; const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set()); const navRef = useRef<HTMLElement>(null);
// Reset expanded items when drawer closes useEffect(() => { if (!isOpen) { setExpandedItems(new Set()); } }, [isOpen]);
if (!menuData) return null;
const toggleExpanded = (itemId: number) => { setExpandedItems((current) => { const next = new Set(current); if (next.has(itemId)) { next.delete(itemId); } else { next.add(itemId); } return next; }); };
return ( <Drawer isOpen={isOpen} onClose={close} position="left" title="Menu" id="mobile-menu" > <nav ref={navRef} className={styles.nav} aria-label="Mobile navigation"> <ul className={styles.list}> {menuData.items.map((item) => ( <MobileNavItem key={item.id} item={item} isExpanded={expandedItems.has(item.id)} onToggle={() => toggleExpanded(item.id)} onNavigate={close} level={0} /> ))} </ul> </nav> </Drawer> );}Mobile Nav Item Component
import { useState } from 'react';import type { MenuItem } from '@/types/navigation';import styles from './MobileNavItem.module.css';
interface MobileNavItemProps { item: MenuItem; isExpanded: boolean; onToggle: () => void; onNavigate: () => void; level: number;}
export function MobileNavItem({ item, isExpanded, onToggle, onNavigate, level,}: MobileNavItemProps) { const hasChildren = item.childLinks.length > 0; const [childExpanded, setChildExpanded] = useState<Set<number>>(new Set());
const toggleChild = (childId: number) => { setChildExpanded((current) => { const next = new Set(current); if (next.has(childId)) { next.delete(childId); } else { next.add(childId); } return next; }); };
const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onToggle(); } };
return ( <li className={styles.item}> <div className={styles.header} style={{ paddingLeft: `${level * 1}rem` }}> <a href={item.url} className={`${styles.link} ${item.active ? styles.current : ''}`} onClick={onNavigate} > {item.title} </a>
{hasChildren && ( <button type="button" className={`${styles.toggle} ${isExpanded ? styles.expanded : ''}`} onClick={onToggle} onKeyDown={handleKeyDown} aria-expanded={isExpanded} aria-label={`${isExpanded ? 'Collapse' : 'Expand'} ${item.title} submenu`} > <ChevronIcon /> </button> )} </div>
{hasChildren && isExpanded && ( <ul className={styles.sublist}> {item.childLinks.map((child) => ( <MobileNavItem key={child.id} item={child} isExpanded={childExpanded.has(child.id)} onToggle={() => toggleChild(child.id)} onNavigate={onNavigate} level={level + 1} /> ))} </ul> )} </li> );}
function ChevronIcon() { return ( <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" > <path d="M6 8l4 4 4-4" /> </svg> );}Mobile Nav Styles
.nav { padding: 1rem 0;}
.list { margin: 0; padding: 0; list-style: none;}.item { border-bottom: 1px solid var(--color-border);}
.item:last-child { border-bottom: none;}
.header { display: flex; align-items: center; justify-content: space-between; padding: 0 1rem;}
.link { flex: 1; padding: 1rem 0; font-size: 1rem; font-weight: 500; color: var(--color-text); text-decoration: none;}
.link:hover { opacity: 0.7;}
.link.current { font-weight: 600;}
.toggle { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; padding: 0; border: none; background: transparent; color: var(--color-text-muted); cursor: pointer; transition: transform 0.2s ease;}
.toggle:hover { color: var(--color-text);}
.toggle.expanded { transform: rotate(180deg);}
.sublist { margin: 0; padding: 0 0 0.5rem; list-style: none; background: var(--color-background-subtle);}
.sublist .link { font-size: 0.9375rem; font-weight: 400; color: var(--color-text-muted);}
.sublist .link:hover { color: var(--color-text);}The Drawer Component
A reusable drawer for mobile navigation:
import { useEffect, useRef } from 'react';import { createPortal } from 'react-dom';import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';import { useFocusTrap } from '@/hooks/useFocusTrap';import styles from './Drawer.module.css';
interface DrawerProps { isOpen: boolean; onClose: () => void; position: 'left' | 'right'; title: string; children: React.ReactNode; id?: string;}
export function Drawer({ isOpen, onClose, position, title, children, id,}: DrawerProps) { const drawerRef = useRef<HTMLDivElement>(null);
// Lock body scroll when open useBodyScrollLock(isOpen);
// Trap focus within drawer useFocusTrap(drawerRef, isOpen);
// Close on Escape useEffect(() => { const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && isOpen) { onClose(); } };
document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); }, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal( <div className={styles.overlay} onClick={onClose}> <div ref={drawerRef} className={`${styles.drawer} ${styles[position]}`} onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby={`${id}-title`} id={id} > <div className={styles.header}> <h2 id={`${id}-title`} className={styles.title}> {title} </h2> <button type="button" className={styles.close} onClick={onClose} aria-label="Close menu" > <CloseIcon /> </button> </div>
<div className={styles.content}>{children}</div> </div> </div>, document.body );}
function CloseIcon() { return ( <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <path d="M18 6L6 18M6 6l12 12" /> </svg> );}.overlay { position: fixed; inset: 0; z-index: 1000; background: rgba(0, 0, 0, 0.5); animation: fadeIn 0.2s ease;}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; }}
.drawer { position: fixed; top: 0; bottom: 0; width: 100%; max-width: 320px; background: var(--color-background); display: flex; flex-direction: column; animation: slideIn 0.3s ease;}
.left { left: 0;}
.right { right: 0;}
@keyframes slideIn { from { transform: translateX(-100%); } to { transform: translateX(0); }}
.right.drawer { animation-name: slideInRight;}
@keyframes slideInRight { from { transform: translateX(100%); } to { transform: translateX(0); }}
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem; border-bottom: 1px solid var(--color-border);}
.title { margin: 0; font-size: 1.125rem; font-weight: 600;}
.close { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; padding: 0; border: none; background: transparent; cursor: pointer; color: var(--color-text);}
.close:hover { opacity: 0.7;}
.content { flex: 1; overflow-y: auto;}Focus Trap Hook
Trap focus within the drawer:
/* * Focus Trap Hook - Critical for Accessibility * * When a modal/drawer opens, keyboard users must be able to Tab through * all focusable elements inside, but NOT escape to the background. * This hook: * 1. Moves focus into the container on open * 2. Cycles Tab from last→first and Shift+Tab from first→last * 3. Restores focus to the trigger element on close */import { useEffect } from 'react';
export function useFocusTrap( containerRef: React.RefObject<HTMLElement>, isActive: boolean) { useEffect(() => { if (!isActive || !containerRef.current) return;
const container = containerRef.current; // All elements that can receive keyboard focus const focusableSelector = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const focusableElements = container.querySelectorAll<HTMLElement>(focusableSelector); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1];
// Remember what was focused before opening (the trigger button) const previouslyFocused = document.activeElement as HTMLElement;
// Move focus into the container firstElement?.focus();
const handleTab = (event: KeyboardEvent) => { if (event.key !== 'Tab') return;
// Shift+Tab on first element → wrap to last if (event.shiftKey) { if (document.activeElement === firstElement) { event.preventDefault(); lastElement?.focus(); } } else { // Tab on last element → wrap to first if (document.activeElement === lastElement) { event.preventDefault(); firstElement?.focus(); } } };
container.addEventListener('keydown', handleTab);
// Cleanup: remove listener and restore focus to trigger return () => { container.removeEventListener('keydown', handleTab); // Return focus to the button that opened the modal previouslyFocused?.focus(); }; }, [containerRef, isActive]);}Body Scroll Lock Hook
Prevent background scrolling:
import { useEffect } from 'react';
export function useBodyScrollLock(isLocked: boolean) { useEffect(() => { if (!isLocked) return;
const scrollY = window.scrollY; const body = document.body;
// Lock scroll body.style.position = 'fixed'; body.style.top = `-${scrollY}px`; body.style.left = '0'; body.style.right = '0'; body.style.overflow = 'hidden';
return () => { // Restore scroll body.style.position = ''; body.style.top = ''; body.style.left = ''; body.style.right = ''; body.style.overflow = ''; window.scrollTo(0, scrollY); }; }, [isLocked]);}Mounting the Mobile Navigation
import { createRoot } from 'react-dom/client';import { MobileNav } from '@/components/header/MobileNav';
function mountMobileNav() { // Create mount point if it doesn't exist let container = document.getElementById('mobile-nav-root');
if (!container) { container = document.createElement('div'); container.id = 'mobile-nav-root'; document.body.appendChild(container); }
const root = createRoot(container); root.render(<MobileNav />);}
// Include in your header mount functionmountMobileNav();Key Takeaways
- Drawer pattern: Full-height sliding panel for mobile
- Accordion navigation: Expand/collapse nested menus
- Focus trap: Keep focus within the open drawer
- Body scroll lock: Prevent background scrolling
- Close on navigate: Close drawer when clicking a link
- Reset on close: Clear expanded items when drawer closes
- Portal rendering: Render drawer at document.body level
In the next lesson, we’ll build the announcement bar and sticky header behavior.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...