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.

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

src/types/navigation.ts
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

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

src/components/header/DesktopNav/NavDropdown.tsx
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>
);
}
src/components/header/DesktopNav/NavDropdown.module.css
.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:

src/components/header/DesktopNav/MegaMenuPanel.tsx
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>
);
}
src/components/header/DesktopNav/MegaMenuPanel.module.css
.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:

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

src/components/header/DesktopNav/DesktopNav.module.css
.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

  1. Delayed close: Use a timeout to prevent accidental menu closes
  2. Two menu types: Simple dropdowns for shallow menus, mega menus for deep navigation
  3. Featured content: Include promotional images from metafields
  4. Keyboard navigation: Arrow keys, Tab, Escape, Home, End
  5. ARIA roles: Use menubar, menu, and menuitem correctly
  6. 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...