Global UI: Header and Navigation Intermediate 12 min read

Desktop Nav Patterns: Dropdowns and Megamenus

Build accessible dropdown menus and megamenus for desktop navigation, with CSS-only and JavaScript-enhanced approaches.

Desktop navigation often requires dropdown menus for nested content. This lesson covers patterns from simple dropdowns to full-featured megamenus, with a focus on accessibility and usability.

Simple CSS Dropdown

The most basic dropdown uses hover states:

<nav class="nav">
<ul class="nav__list">
{%- for link in menu.links -%}
<li class="nav__item">
<a href="{{ link.url }}" class="nav__link">{{ link.title }}</a>
{%- if link.links.size > 0 -%}
<ul class="nav__dropdown">
{%- for child in link.links -%}
<li>
<a href="{{ child.url }}" class="nav__dropdown-link">
{{ child.title }}
</a>
</li>
{%- endfor -%}
</ul>
{%- endif -%}
</li>
{%- endfor -%}
</ul>
</nav>
.nav__list {
display: flex;
gap: var(--spacing-md);
list-style: none;
margin: 0;
padding: 0;
}
.nav__item {
position: relative;
}
.nav__link {
display: block;
padding: var(--spacing-sm) var(--spacing-md);
color: inherit;
text-decoration: none;
}
/* Dropdown hidden by default */
.nav__dropdown {
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
padding: var(--spacing-sm) 0;
background: var(--color-background);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
list-style: none;
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition:
opacity 0.2s,
transform 0.2s,
visibility 0.2s;
}
/* Show on hover */
.nav__item:hover .nav__dropdown,
.nav__item:focus-within .nav__dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.nav__dropdown-link {
display: block;
padding: var(--spacing-xs) var(--spacing-md);
color: inherit;
text-decoration: none;
}
.nav__dropdown-link:hover {
background: var(--color-background-secondary);
}

Using the Details Element

For better accessibility without JavaScript:

<nav class="nav">
<ul class="nav__list">
{%- for link in menu.links -%}
<li class="nav__item">
{%- if link.links.size > 0 -%}
<details class="nav__details">
<summary class="nav__link nav__link--parent">
<span>{{ link.title }}</span>
<svg class="nav__arrow" viewBox="0 0 24 24" width="16" height="16">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
</summary>
<ul class="nav__dropdown">
{# Optional: Link to parent page #}
<li>
<a href="{{ link.url }}" class="nav__dropdown-link">
View All {{ link.title }}
</a>
</li>
{%- for child in link.links -%}
<li>
<a href="{{ child.url }}" class="nav__dropdown-link">
{{ child.title }}
</a>
</li>
{%- endfor -%}
</ul>
</details>
{%- else -%}
<a href="{{ link.url }}" class="nav__link">{{ link.title }}</a>
{%- endif -%}
</li>
{%- endfor -%}
</ul>
</nav>
.nav__details {
position: relative;
}
.nav__details summary {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
list-style: none;
}
.nav__details summary::-webkit-details-marker {
display: none;
}
.nav__arrow {
transition: transform 0.2s;
}
.nav__details[open] .nav__arrow {
transform: rotate(180deg);
}
.nav__dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 100;
min-width: 220px;
margin-top: var(--spacing-xs);
padding: var(--spacing-sm) 0;
background: var(--color-background);
border: 1px solid var(--color-border);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}

Hover Intent with JavaScript

Prevent accidental dropdowns with hover delay:

class NavDropdown extends HTMLElement {
constructor() {
super();
this.details = this.querySelector('details');
this.timeout = null;
this.hoverDelay = 150;
}
connectedCallback() {
this.addEventListener('mouseenter', this.onMouseEnter.bind(this));
this.addEventListener('mouseleave', this.onMouseLeave.bind(this));
this.addEventListener('focusin', this.onFocusIn.bind(this));
this.addEventListener('focusout', this.onFocusOut.bind(this));
}
onMouseEnter() {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.details.open = true;
}, this.hoverDelay);
}
onMouseLeave() {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.details.open = false;
}, this.hoverDelay);
}
onFocusIn() {
this.details.open = true;
}
onFocusOut(event) {
if (!this.contains(event.relatedTarget)) {
this.details.open = false;
}
}
}
customElements.define('nav-dropdown', NavDropdown);
{%- if link.links.size > 0 -%}
<nav-dropdown>
<details class="nav__details">
<!-- content -->
</details>
</nav-dropdown>
{%- endif -%}

Megamenu Pattern

Megamenus show rich content across the full width:

<nav class="nav">
<ul class="nav__list">
{%- for link in menu.links -%}
<li class="nav__item {% if link.links.size > 0 %}has-megamenu{% endif %}">
<a href="{{ link.url }}" class="nav__link">{{ link.title }}</a>
{%- if link.links.size > 0 -%}
<div class="megamenu">
<div class="megamenu__container container">
<div class="megamenu__columns">
{%- for child in link.links -%}
<div class="megamenu__column">
<h3 class="megamenu__heading">
<a href="{{ child.url }}">{{ child.title }}</a>
</h3>
{%- if child.links.size > 0 -%}
<ul class="megamenu__list">
{%- for grandchild in child.links -%}
<li>
<a href="{{ grandchild.url }}" class="megamenu__link">
{{ grandchild.title }}
</a>
</li>
{%- endfor -%}
</ul>
{%- endif -%}
</div>
{%- endfor -%}
</div>
</div>
</div>
{%- endif -%}
</li>
{%- endfor -%}
</ul>
</nav>
.megamenu {
position: absolute;
top: 100%;
left: 0;
right: 0;
width: 100vw;
margin-left: calc(-50vw + 50%);
padding: var(--spacing-xl) 0;
background: var(--color-background);
border-top: 1px solid var(--color-border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition:
opacity 0.3s,
transform 0.3s,
visibility 0.3s;
}
.nav__item:hover .megamenu,
.nav__item:focus-within .megamenu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.megamenu__columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-lg);
}
.megamenu__heading {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--spacing-sm);
}
.megamenu__heading a {
color: inherit;
text-decoration: none;
}
.megamenu__list {
list-style: none;
padding: 0;
margin: 0;
}
.megamenu__link {
display: block;
padding: var(--spacing-xs) 0;
color: var(--color-text-light);
text-decoration: none;
transition: color 0.2s;
}
.megamenu__link:hover {
color: var(--color-text);
}

Include images or promotions:

<div class="megamenu">
<div class="megamenu__container container">
<div class="megamenu__grid">
{# Navigation columns #}
<div class="megamenu__nav">
{%- for child in link.links -%}
<div class="megamenu__column">
<h3 class="megamenu__heading">{{ child.title }}</h3>
<ul class="megamenu__list">
{%- for grandchild in child.links -%}
<li>
<a href="{{ grandchild.url }}">{{ grandchild.title }}</a>
</li>
{%- endfor -%}
</ul>
</div>
{%- endfor -%}
</div>
{# Featured content #}
<div class="megamenu__featured">
{%- if link.type == 'collection_link' and link.object.image -%}
<a href="{{ link.url }}" class="megamenu__promo">
<img
src="{{ link.object.image | image_url: width: 400 }}"
alt="{{ link.object.title }}"
loading="lazy"
>
<span class="megamenu__promo-text">
Shop {{ link.object.title }}
</span>
</a>
{%- endif -%}
</div>
</div>
</div>
</div>
.megamenu__grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: var(--spacing-xl);
}
.megamenu__nav {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-lg);
}
.megamenu__featured {
border-left: 1px solid var(--color-border);
padding-left: var(--spacing-xl);
}
.megamenu__promo {
display: block;
position: relative;
overflow: hidden;
border-radius: var(--border-radius);
}
.megamenu__promo img {
width: 100%;
height: auto;
transition: transform 0.3s;
}
.megamenu__promo:hover img {
transform: scale(1.05);
}
.megamenu__promo-text {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--spacing-md);
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
font-weight: 600;
}

Keyboard Navigation

Ensure dropdowns work with keyboard:

class AccessibleNav extends HTMLElement {
connectedCallback() {
this.querySelectorAll('.nav__link--parent').forEach((trigger) => {
trigger.addEventListener('keydown', this.onKeydown.bind(this));
});
this.querySelectorAll('.nav__dropdown').forEach((dropdown) => {
dropdown.addEventListener('keydown', this.onDropdownKeydown.bind(this));
});
}
onKeydown(event) {
const item = event.target.closest('.nav__item');
const dropdown = item.querySelector('.nav__dropdown');
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
this.toggleDropdown(item, dropdown);
break;
case 'ArrowDown':
event.preventDefault();
this.openDropdown(item, dropdown);
dropdown.querySelector('a')?.focus();
break;
case 'Escape':
this.closeDropdown(item, dropdown);
break;
}
}
onDropdownKeydown(event) {
const links = Array.from(event.currentTarget.querySelectorAll('a'));
const currentIndex = links.indexOf(document.activeElement);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
links[currentIndex + 1]?.focus();
break;
case 'ArrowUp':
event.preventDefault();
if (currentIndex > 0) {
links[currentIndex - 1].focus();
} else {
event.currentTarget.closest('.nav__item').querySelector('.nav__link').focus();
}
break;
case 'Escape':
const item = event.currentTarget.closest('.nav__item');
this.closeDropdown(item, event.currentTarget);
item.querySelector('.nav__link').focus();
break;
}
}
toggleDropdown(item, dropdown) {
const isOpen = item.classList.contains('is-open');
this.closeAll();
if (!isOpen) {
this.openDropdown(item, dropdown);
}
}
openDropdown(item, dropdown) {
item.classList.add('is-open');
item.querySelector('.nav__link').setAttribute('aria-expanded', 'true');
}
closeDropdown(item, dropdown) {
item.classList.remove('is-open');
item.querySelector('.nav__link').setAttribute('aria-expanded', 'false');
}
closeAll() {
this.querySelectorAll('.nav__item.is-open').forEach((item) => {
this.closeDropdown(item, item.querySelector('.nav__dropdown'));
});
}
}
customElements.define('accessible-nav', AccessibleNav);

ARIA Attributes

Proper accessibility markup:

<li class="nav__item">
<a
href="{{ link.url }}"
class="nav__link"
{% if link.links.size > 0 %}
aria-haspopup="true"
aria-expanded="false"
aria-controls="dropdown-{{ link.handle }}"
{% endif %}
>
{{ link.title }}
</a>
{%- if link.links.size > 0 -%}
<ul
class="nav__dropdown"
id="dropdown-{{ link.handle }}"
role="menu"
aria-label="{{ link.title }} submenu"
>
{%- for child in link.links -%}
<li role="none">
<a href="{{ child.url }}" role="menuitem">
{{ child.title }}
</a>
</li>
{%- endfor -%}
</ul>
{%- endif -%}
</li>

Close on Outside Click

document.addEventListener('click', (event) => {
const nav = document.querySelector('.nav');
if (!nav.contains(event.target)) {
nav.querySelectorAll('.nav__item.is-open').forEach((item) => {
item.classList.remove('is-open');
item.querySelector('[aria-expanded]')?.setAttribute('aria-expanded', 'false');
});
}
});

Practice Exercise

Build a navigation that:

  1. Shows dropdowns on hover with delay
  2. Supports keyboard navigation
  3. Includes a megamenu with featured image for collection links
  4. Closes when clicking outside

Consider:

  • What happens when someone hovers quickly across menu items?
  • How do screen reader users know a dropdown exists?
  • What’s the tab order through the navigation?

Key Takeaways

  1. CSS-only dropdowns work but lack hover intent
  2. The <details> element provides built-in toggle behavior
  3. Hover delay prevents accidental activations
  4. Megamenus span full width for rich content
  5. Include collection images for visual megamenus
  6. Keyboard navigation requires JavaScript
  7. Use ARIA attributes (aria-expanded, aria-haspopup)
  8. Close on outside click for better UX

What’s Next?

Desktop navigation works, but mobile needs different patterns. The next lesson covers Mobile Nav Patterns: Drawers and Accordions for touch-friendly navigation.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...