Header and Navigation Components Intermediate 15 min read
Desktop Navigation with Dropdowns and Mega Menus
Build accessible desktop navigation with dropdown menus and mega menus. Pass Shopify menu data from Liquid to React and create keyboard-navigable interfaces.
Desktop navigation requires careful attention to hover interactions, keyboard accessibility, and nested menu structures. In this lesson, we’ll build both simple dropdowns and rich mega menus.
Menu Data from Liquid
First, serialize your Shopify menu in Liquid:
{% comment %} sections/header.liquid {% endcomment %}<script type="application/json" id="menu-data"> { "items": [ {%- for link in section.settings.menu.links -%} { "id": {{ forloop.index | json }}, "title": {{ link.title | json }}, "url": {{ link.url | json }}, "active": {{ link.active | json }}, "childLinks": [ {%- for child in link.links -%} { "id": {{ forloop.index | json }}, "title": {{ child.title | json }}, "url": {{ child.url | json }}, "active": {{ child.active | json }}, "childLinks": [ {%- for grandchild in child.links -%} { "id": {{ forloop.index | json }}, "title": {{ grandchild.title | json }}, "url": {{ grandchild.url | json }}, "active": {{ grandchild.active | json }} }{%- unless forloop.last -%},{%- endunless -%} {%- endfor -%} ] }{%- unless forloop.last -%},{%- endunless -%} {%- endfor -%} ], "megaMenu": {{ link.object.metafields.custom.mega_menu | default: false | json }}, "featured": {%- if link.object.metafields.custom.featured_image -%} { "image": {{ link.object.metafields.custom.featured_image | image_url: width: 400 | json }}, "title": {{ link.object.metafields.custom.featured_title | json }}, "url": {{ link.object.metafields.custom.featured_url | json }} } {%- else -%}null{%- endif -%} }{%- unless forloop.last -%},{%- endunless -%} {%- endfor -%} ] }</script>TypeScript Types
export interface MenuItem { id: number; title: string; url: string; active: boolean; childLinks: MenuItem[]; megaMenu?: boolean; featured?: { image: string; title: string; url: string; } | null;}
export interface MenuData { items: MenuItem[];}Desktop Navigation Component
/* * Desktop Navigation with Hover Dropdowns * * Key patterns: * - Delayed close: 150ms timeout prevents accidental close when moving mouse * - Keyboard support: Enter/Space toggle, Escape closes, ArrowDown opens * - Two menu types: simple dropdown or rich mega menu */import { useState, useRef, useCallback } from 'react';import { readJsonScript } from '@/utils/data-bridge';import type { MenuData, MenuItem } from '@/types/navigation';import { NavDropdown } from './NavDropdown';import { MegaMenuPanel } from './MegaMenuPanel';import styles from './DesktopNav.module.css';
export function DesktopNav() { // Read menu data from Liquid-rendered JSON script tag const menuData = readJsonScript('menu-data') as MenuData | null; // Track which menu item is open (by ID) const [activeMenu, setActiveMenu] = useState<number | null>(null); // Ref to store timeout ID for delayed close const timeoutRef = useRef<number | null>(null);
if (!menuData) return null;
// On hover: immediately open, cancel any pending close const handleMouseEnter = useCallback((itemId: number) => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } setActiveMenu(itemId); }, []);
// On leave: delay close by 150ms (allows mouse to move to dropdown) const handleMouseLeave = useCallback(() => { timeoutRef.current = window.setTimeout(() => { setActiveMenu(null); }, 150); // Small delay prevents accidental close }, []);
// Keyboard navigation for accessibility const handleKeyDown = useCallback( (event: React.KeyboardEvent, itemId: number) => { switch (event.key) { case 'Enter': case ' ': event.preventDefault(); // Toggle: if open, close; if closed, open setActiveMenu(activeMenu === itemId ? null : itemId); break; case 'Escape': setActiveMenu(null); break; case 'ArrowDown': event.preventDefault(); setActiveMenu(itemId); break; } }, [activeMenu] );
return ( <nav className={styles.nav} aria-label="Main navigation"> <ul className={styles.menu} role="menubar"> {menuData.items.map((item) => ( <NavItem key={item.id} item={item} isActive={activeMenu === item.id} onMouseEnter={() => handleMouseEnter(item.id)} onMouseLeave={handleMouseLeave} onKeyDown={(event) => handleKeyDown(event, item.id)} /> ))} </ul> </nav> );}
interface NavItemProps { item: MenuItem; isActive: boolean; onMouseEnter: () => void; onMouseLeave: () => void; onKeyDown: (event: React.KeyboardEvent) => void;}
function NavItem({ item, isActive, onMouseEnter, onMouseLeave, onKeyDown,}: NavItemProps) { const hasChildren = item.childLinks.length > 0; const isMegaMenu = item.megaMenu && hasChildren;
if (!hasChildren) { return ( <li className={styles.item} role="none"> <a href={item.url} className={`${styles.link} ${item.active ? styles.current : ''}`} role="menuitem" > {item.title} </a> </li> ); }
return ( <li className={styles.item} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} role="none" > <button type="button" className={`${styles.trigger} ${isActive ? styles.active : ''}`} aria-expanded={isActive} aria-haspopup="true" onKeyDown={onKeyDown} role="menuitem" > {item.title} <ChevronIcon className={`${styles.chevron} ${isActive ? styles.rotated : ''}`} /> </button>
{isActive && ( isMegaMenu ? ( <MegaMenuPanel item={item} onClose={() => onMouseLeave()} /> ) : ( <NavDropdown item={item} /> ) )} </li> );}
function ChevronIcon({ className }: { className?: string }) { return ( <svg className={className} width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" > <path d="M3 4.5L6 7.5L9 4.5" /> </svg> );}Simple Dropdown Component
import type { MenuItem } from '@/types/navigation';import styles from './NavDropdown.module.css';
interface NavDropdownProps { item: MenuItem;}
export function NavDropdown({ item }: NavDropdownProps) { return ( <div className={styles.dropdown} role="menu" aria-label={`${item.title} submenu`}> <ul className={styles.list}> {item.childLinks.map((child) => ( <li key={child.id} role="none"> <a href={child.url} className={`${styles.link} ${child.active ? styles.current : ''}`} role="menuitem" > {child.title} </a>
{child.childLinks.length > 0 && ( <ul className={styles.sublist}> {child.childLinks.map((grandchild) => ( <li key={grandchild.id} role="none"> <a href={grandchild.url} className={styles.sublink} role="menuitem"> {grandchild.title} </a> </li> ))} </ul> )} </li> ))} </ul> </div> );}.dropdown { position: absolute; top: 100%; left: 0; z-index: 100; min-width: 200px; padding: 0.5rem 0; background: var(--color-background); border: 1px solid var(--color-border); border-radius: 8px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); animation: fadeIn 0.15s ease;}
@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); }}
.list { margin: 0; padding: 0; list-style: none;}
.link { display: block; padding: 0.625rem 1rem; font-size: 0.9375rem; color: var(--color-text); text-decoration: none; transition: background 0.15s ease;}
.link:hover { background: var(--color-background-subtle);}
.link.current { font-weight: 600;}
.sublist { margin: 0; padding: 0 0 0 1rem; list-style: none;}
.sublink { display: block; padding: 0.5rem 1rem; font-size: 0.875rem; color: var(--color-text-muted); text-decoration: none;}
.sublink:hover { color: var(--color-text);}Mega Menu Panel
For rich navigation with images and multiple columns:
import type { MenuItem } from '@/types/navigation';import styles from './MegaMenuPanel.module.css';
interface MegaMenuPanelProps { item: MenuItem; onClose: () => void;}
export function MegaMenuPanel({ item, onClose }: MegaMenuPanelProps) { return ( <div className={styles.panel} role="menu" aria-label={`${item.title} menu`} onMouseLeave={onClose} > <div className={styles.container}> <div className={styles.columns}> {item.childLinks.map((column) => ( <div key={column.id} className={styles.column}> <a href={column.url} className={styles.columnTitle}> {column.title} </a>
{column.childLinks.length > 0 && ( <ul className={styles.columnLinks}> {column.childLinks.map((link) => ( <li key={link.id}> <a href={link.url} className={styles.columnLink}> {link.title} </a> </li> ))} </ul> )} </div> ))} </div>
{item.featured && ( <div className={styles.featured}> <a href={item.featured.url} className={styles.featuredLink}> <img src={item.featured.image} alt="" className={styles.featuredImage} loading="lazy" /> <span className={styles.featuredTitle}>{item.featured.title}</span> </a> </div> )} </div> </div> );}.panel { position: absolute; top: 100%; left: 50%; transform: translateX(-50%); z-index: 100; width: 100%; max-width: 900px; padding-top: 0.5rem; animation: fadeIn 0.15s ease;}
@keyframes fadeIn { from { opacity: 0; transform: translateX(-50%) translateY(-8px); } to { opacity: 1; transform: translateX(-50%) translateY(0); }}
.container { display: flex; gap: 2rem; padding: 2rem; background: var(--color-background); border: 1px solid var(--color-border); border-radius: 8px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);}
.columns { display: flex; gap: 2rem; flex: 1;}
.column { min-width: 150px;}
.columnTitle { display: block; margin-bottom: 0.75rem; font-weight: 600; font-size: 0.9375rem; color: inherit; text-decoration: none;}
.columnTitle:hover { text-decoration: underline;}
.columnLinks { margin: 0; padding: 0; list-style: none;}
.columnLink { display: block; padding: 0.375rem 0; font-size: 0.875rem; color: var(--color-text-muted); text-decoration: none; transition: color 0.15s ease;}
.columnLink:hover { color: var(--color-text);}
.featured { flex-shrink: 0; width: 220px;}
.featuredLink { display: block; text-decoration: none; color: inherit;}
.featuredImage { display: block; width: 100%; aspect-ratio: 4 / 5; object-fit: cover; border-radius: 4px; margin-bottom: 0.75rem;}
.featuredTitle { font-weight: 500; font-size: 0.9375rem;}Keyboard Navigation
Add comprehensive keyboard support:
import { useCallback, useEffect, useRef } from 'react';
interface UseMenuKeyboardOptions { isOpen: boolean; onClose: () => void; menuRef: React.RefObject<HTMLElement>;}
export function useMenuKeyboard({ isOpen, onClose, menuRef }: UseMenuKeyboardOptions) { const focusableSelector = 'a[href], button:not([disabled])';
useEffect(() => { if (!isOpen || !menuRef.current) return;
const menu = menuRef.current; const focusableElements = menu.querySelectorAll<HTMLElement>(focusableSelector); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1];
const handleKeyDown = (event: KeyboardEvent) => { switch (event.key) { case 'Escape': event.preventDefault(); onClose(); break;
case 'Tab': if (event.shiftKey && document.activeElement === firstElement) { event.preventDefault(); lastElement?.focus(); } else if (!event.shiftKey && document.activeElement === lastElement) { event.preventDefault(); firstElement?.focus(); } break;
case 'ArrowDown': event.preventDefault(); focusNextItem(menu, focusableSelector); break;
case 'ArrowUp': event.preventDefault(); focusPreviousItem(menu, focusableSelector); break;
case 'Home': event.preventDefault(); firstElement?.focus(); break;
case 'End': event.preventDefault(); lastElement?.focus(); break; } };
menu.addEventListener('keydown', handleKeyDown); firstElement?.focus();
return () => menu.removeEventListener('keydown', handleKeyDown); }, [isOpen, onClose, menuRef]);}
function focusNextItem(menu: HTMLElement, selector: string) { const items = Array.from(menu.querySelectorAll<HTMLElement>(selector)); const currentIndex = items.findIndex((item) => item === document.activeElement); const nextIndex = currentIndex + 1 < items.length ? currentIndex + 1 : 0; items[nextIndex]?.focus();}
function focusPreviousItem(menu: HTMLElement, selector: string) { const items = Array.from(menu.querySelectorAll<HTMLElement>(selector)); const currentIndex = items.findIndex((item) => item === document.activeElement); const previousIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : items.length - 1; items[previousIndex]?.focus();}Desktop Nav Styles
.nav { display: none; flex: 1;}
@media (min-width: 1024px) { .nav { display: block; }}
.menu { display: flex; align-items: center; justify-content: center; gap: 0; margin: 0; padding: 0; list-style: none;}
.item { position: relative;}
.link,.trigger { display: flex; align-items: center; gap: 0.25rem; padding: 0.75rem 1rem; font-size: 0.9375rem; font-weight: 500; color: inherit; text-decoration: none; background: none; border: none; cursor: pointer; transition: opacity 0.15s ease;}
.link:hover,.trigger:hover { opacity: 0.7;}
.trigger.active { opacity: 0.7;}
.current { font-weight: 600;}
.chevron { transition: transform 0.2s ease;}
.rotated { transform: rotate(180deg);}Key Takeaways
- Delayed close: Use a timeout to prevent accidental menu closes
- Two menu types: Simple dropdowns for shallow menus, mega menus for deep navigation
- Featured content: Include promotional images from metafields
- Keyboard navigation: Arrow keys, Tab, Escape, Home, End
- ARIA roles: Use
menubar,menu, andmenuitemcorrectly - Focus management: Trap focus in open menus, return focus on close
In the next lesson, we’ll build the mobile navigation drawer.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...