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);}Megamenu with Featured Content
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:
- Shows dropdowns on hover with delay
- Supports keyboard navigation
- Includes a megamenu with featured image for collection links
- 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
- CSS-only dropdowns work but lack hover intent
- The
<details>element provides built-in toggle behavior - Hover delay prevents accidental activations
- Megamenus span full width for rich content
- Include collection images for visual megamenus
- Keyboard navigation requires JavaScript
- Use ARIA attributes (
aria-expanded,aria-haspopup) - 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...