Header and Navigation Components Intermediate 12 min read

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/StateSourceLiquid Field
menuDataJSON script elementlinklist.links (serialized in header.liquid)
menuData.items[].titleFrom JSONlink.title
menuData.items[].urlFrom JSONlink.url
menuData.items[].activeFrom JSONlink.active
menuData.items[].childLinksFrom JSONlink.links (nested)
isOpenZustand store (useMobileMenu)-
expandedItemsLocal state-

Note: The menuData is parsed from a <script type="application/json" id="menu-data"> element rendered by sections/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

src/components/header/MobileNav/MobileNav.tsx
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

src/components/header/MobileNav/MobileNavItem.tsx
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

src/components/header/MobileNav/MobileNav.module.css
.nav {
padding: 1rem 0;
}
.list {
margin: 0;
padding: 0;
list-style: none;
}
src/components/header/MobileNav/MobileNavItem.module.css
.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:

src/components/ui/Drawer/Drawer.tsx
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>
);
}
src/components/ui/Drawer/Drawer.module.css
.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:

src/hooks/useFocusTrap.ts
/*
* 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:

src/hooks/useBodyScrollLock.ts
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

src/entries/header.tsx
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 function
mountMobileNav();

Key Takeaways

  1. Drawer pattern: Full-height sliding panel for mobile
  2. Accordion navigation: Expand/collapse nested menus
  3. Focus trap: Keep focus within the open drawer
  4. Body scroll lock: Prevent background scrolling
  5. Close on navigate: Close drawer when clicking a link
  6. Reset on close: Clear expanded items when drawer closes
  7. 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...