MegaMenu
The MegaMenu provide a convenient way of high-level navigation to users. By default, it remains hidden to avoid interrupting the user's browsing experience. It can be activated either through a click or hover action, revealing a large drop-down menu on desktop or a side sheet on mobile devices. This expanded menu not only includes a site-map for easy navigation but also incorporates additional elements such as banners.
The MegaMenu is particularly useful for websites that feature multiple levels of navigation and numerous subcategories. It enables users to efficiently explore and access various sections of the website, enhancing their overall browsing experience.
Accessibility notes
MegaMenu complies with the WCAG guidelines for accessibility for menus and menu bars (keyboard support). Learn more on the W3C: Menu and Menubar Pattern (opens new window) page.
Base Mega Menu
When the user clicks on the trigger element (such as one of the menu items), the mega menu opens. On mobile screens, clicking on hamburger button will trigger a drawer opening from the left side with menu content.
<template>
<div class="w-full h-full">
<div v-if="isOpen" class="fixed w-screen h-screen inset-0 bg-neutral-500 bg-opacity-50 transition-opacity" />
<header
ref="menuRef"
class="flex justify-center w-full border-0 bg-primary-700 border-neutral-200 h-14 md:relative md:h-20 md:z-10"
>
<div class="flex items-center flex-nowrap justify-start h-full max-w-[1536px] w-full px-4 md:px-10">
<a
href="#"
aria-label="SF Homepage"
class="flex shrink-0 text-white mr-2 md:mr-10 focus-visible:outline focus-visible:outline-offset focus-visible:rounded-sm"
>
<picture>
<source srcset="https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/vsf_logo_white.svg" media="(min-width: 1024px)" />
<img
src="https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/vsf_logo_sign_white.svg"
alt="Sf Logo"
class="w-8 h-8 lg:w-[12.5rem] lg:h-[1.75rem]"
/>
</picture>
</a>
<nav class="flex w-full justify-between flex-nowrap" aria-label="SF Navigation">
<ul>
<li role="none">
<SfButton
class="block mr-auto text-white font-body bg-transparent hover:bg-primary-800 hover:text-white active:bg-primary-900 active:text-white"
type="button"
:aria-haspopup="true"
:aria-expanded="isOpen"
variant="tertiary"
@click="toggle()"
>
<template #suffix>
<SfIconExpandMore />
</template>
<span class="hidden md:inline-flex">Browse products</span>
</SfButton>
<transition
enter-active-class="transform transition duration-500 ease-in-out"
leave-active-class="transform transition duration-500 ease-in-out"
enter-from-class="-translate-x-full md:translate-x-0 md:opacity-0"
enter-to-class="translate-x-0 md:translate-x-0 md:opacity-100"
leave-from-class="translate-x-0 md:opacity-100"
leave-to-class="-translate-x-full md:translate-x-0 md:opacity-0"
>
<SfDrawer
ref="drawerRef"
v-model="isOpen"
disable-click-away
placement="top"
class="grid grid-cols-1 md:gap-x-6 md:grid-cols-4 bg-white max-w-xs shadow-lg p-0 !fixed max-h-screen overflow-y-auto md:!absolute md:!top-[5rem] md:max-w-full md:p-6"
>
<div class="flex items-center justify-between py-2 px-4 bg-primary-700 md:hidden">
<div class="flex items-center typography-text-lg font-medium text-white">Browse products</div>
<SfButton
square
variant="tertiary"
aria-label="Close navigation menu"
class="text-white"
@click="close()"
@keydown.enter.space="close()"
>
<SfIconClose />
</SfButton>
</div>
<div
v-for="{ heading, items } in categoriesContent"
:key="heading"
class="[&:nth-child(2)]:pt-0 pt-6 md:pt-0"
>
<h2
role="presentation"
class="typography-text-base font-medium text-neutral-900 whitespace-nowrap p-4 md:py-1.5"
>
{{ heading }}
</h2>
<hr class="mb-3.5" />
<ul>
<li v-for="item in items" :key="item.title">
<SfListItem
tag="a"
:href="item.link"
size="sm"
role="none"
class="typography-text-base md:typography-text-sm py-4 md:py-1.5"
>
{{ item.title }}
</SfListItem>
</li>
</ul>
</div>
<div
class="flex flex-col items-center justify-center bg-neutral-100 md:rounded-md border-neutral-300 overflow-hidden grow"
>
<img :src="bannerDetails.image" :alt="bannerDetails.title" class="object-contain" />
<p class="mb-4 mt-4 px-4 text-center typography-text-base font-medium">{{ bannerDetails.title }}</p>
</div>
<SfButton
square
size="sm"
variant="tertiary"
aria-label="Close navigation menu"
class="hidden md:block md:absolute md:right-0 hover:bg-white active:bg-white"
@click="close()"
>
<SfIconClose class="text-neutral-500" />
</SfButton>
</SfDrawer>
</transition>
</li>
</ul>
<div class="flex flex-nowrap">
<SfButton
v-for="actionItem in actionItems"
:key="actionItem.ariaLabel"
class="mr-2 -ml-0.5 text-white bg-transparent hover:bg-primary-800 hover:text-white active:bg-primary-900 active:text-white"
:aria-label="actionItem.ariaLabel"
variant="tertiary"
square
>
<template #prefix>
<Component :is="actionItem.icon" />
</template>
<span v-if="actionItem.role === 'login'" class="hidden md:inline-flex">{{ actionItem.label }}</span>
</SfButton>
</div>
</nav>
</div>
</header>
</div>
</template>
<script lang="ts" setup>
import {
SfButton,
SfDrawer,
SfIconShoppingCart,
SfIconFavorite,
SfIconPerson,
SfIconClose,
SfIconExpandMore,
SfListItem,
useDisclosure,
useTrapFocus,
} from '@storefront-ui/vue';
import { ref } from 'vue';
import { onClickOutside } from '@vueuse/core';
const { isOpen, toggle, close } = useDisclosure();
const menuRef = ref();
const drawerRef = ref();
useTrapFocus(drawerRef, {
activeState: isOpen,
arrowKeysUpDown: true,
initialFocus: 'container',
});
onClickOutside(menuRef, () => {
close();
});
const actionItems = [
{
icon: SfIconShoppingCart,
label: '',
ariaLabel: 'Cart',
role: 'button',
},
{
icon: SfIconFavorite,
label: '',
ariaLabel: 'Wishlist',
role: 'button',
},
{
icon: SfIconPerson,
label: 'Log in',
ariaLabel: 'Log in',
role: 'login',
},
];
const bannerDetails = {
image: 'https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/watch.png',
title: 'New in designer watches',
};
const categoriesContent = [
{
heading: 'Women',
items: [
{
title: "All Women's",
link: '#',
},
{
title: 'Clothing',
link: '#',
},
{
title: 'Shoes',
link: '#',
},
{
title: 'Accessories',
link: '#',
},
{
title: 'Wearables',
link: '#',
},
{
title: 'Food & Drinks',
link: '#',
},
],
},
{
heading: 'Men',
items: [
{
title: 'All Men’s',
link: '#',
},
{
title: 'Clothing',
link: '#',
},
{
title: 'Shoes',
link: '#',
},
{
title: 'Accessories',
link: '#',
},
{
title: 'Wearables',
link: '#',
},
{
title: 'Food & Drinks',
link: '#',
},
],
},
{
heading: 'Kids',
items: [
{
title: 'All Kids',
link: '#',
},
{
title: 'Clothing',
link: '#',
},
{
title: 'Shoes',
link: '#',
},
{
title: 'Accessories',
link: '#',
},
{
title: 'Wearables',
link: '#',
},
{
title: 'Food & Drinks',
link: '#',
},
],
},
];
</script>
Mega Menu with extended navigation
Additional navigation bar under the main header helps to find general categories. User can easily open Mega Menu for each category. When using on mobile devices side drawer provides a nice way of navigating through nested categories.
<template>
<div class="w-full h-full">
<header ref="referenceRef" class="relative">
<div
class="flex justify-between items-center px-4 md:px-10 w-full border-0 bg-primary-700 border-neutral-200 h-14 md:h-20 md:z-10"
>
<div class="flex">
<SfButton
variant="tertiary"
square
aria-label="Close menu"
class="block md:hidden mr-5 bg-transparent hover:bg-primary-800 hover:text-white active:bg-primary-900 active:text-white"
@click="openMenu([])"
>
<SfIconMenu class="text-white" />
</SfButton>
<a
href="#"
aria-label="SF Homepage"
class="flex items-center mr-2 text-white md:mr-10 focus-visible:outline focus-visible:outline-offset focus-visible:rounded-sm"
>
<picture>
<source srcset="https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/vsf_logo_white.svg" media="(min-width: 1024px)" />
<img
src="https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/vsf_logo_sign_white.svg"
alt="Sf Logo"
class="w-8 h-8 lg:w-[12.5rem] lg:h-[1.75rem]"
/>
</picture>
</a>
</div>
<div class="flex flex-nowrap">
<SfButton
v-for="actionItem in actionItems"
:key="actionItem.ariaLabel"
:aria-label="actionItem.ariaLabel"
class="ml-2 text-white bg-transparent hover:bg-primary-800 hover:text-white active:bg-primary-900 active:text-white"
variant="tertiary"
square
>
<template #prefix>
<Component :is="actionItem.icon" />
</template>
<p v-if="actionItem.role === 'login'" class="hidden md:inline-flex">{{ actionItem.label }}</p>
</SfButton>
</div>
</div>
<!-- Desktop dropdown -->
<nav ref="floatingRef">
<ul
class="hidden md:flex px-6 py-2 bg-white border-b border-b-neutral-200 border-b-solid"
@blur="
(event) => {
if (!(event.currentTarget as Element).contains((event.relatedTarget as Element))) {
close();
}
}
"
>
<li v-for="(menuNode, index) in content.children" :key="menuNode.key">
<SfButton
ref="triggerRefs"
variant="tertiary"
class="group mr-2 !text-neutral-900 hover:!bg-neutral-200 hover:!text-neutral-700 active:!bg-neutral-300 active:!text-neutral-900"
@mouseenter="openMenu([menuNode.key])"
@click="openMenu([menuNode.key])"
>
<span>{{ menuNode.value.label }}</span>
<SfIconChevronRight
class="rotate-90 text-neutral-500 group-hover:text-neutral-700 group-active:text-neutral-900"
/>
</SfButton>
<div
v-if="isOpen && activeNode.length === 1 && activeNode[0] === menuNode.key"
:key="activeMenu.key"
ref="megaMenuRef"
:style="style"
class="hidden md:grid gap-x-6 grid-cols-4 bg-white shadow-lg p-6 left-0 right-0 outline-none"
tabindex="0"
@mouseleave="close()"
@keydown.esc="focusTrigger(index)"
>
<template v-for="node in activeMenu.children" :key="node.key">
<template v-if="node.isLeaf">
<SfListItem tag="a" size="sm" :href="node.value.link" class="typography-text-sm mb-2">
{{ node.value.label }}
</SfListItem>
<div class="col-start-2 col-end-5" />
</template>
<div v-else>
<p
class="typography-text-base font-medium text-neutral-900 whitespace-nowrap px-4 py-1.5 border-b border-b-neutral-200 border-b-solid"
>
{{ node.value.label }}
</p>
<ul class="mt-2">
<li v-for="child in node.children" :key="child.key">
<SfListItem tag="a" size="sm" :href="child.value.link" class="typography-text-sm py-1.5">
{{ child.value.label }}
</SfListItem>
</li>
</ul>
</div>
</template>
<div
class="flex flex-col items-center justify-center overflow-hidden rounded-md bg-neutral-100 border-neutral-300 grow"
>
<img :src="bannerNode.value.banner" :alt="bannerNode.value.bannerTitle" class="object-contain" />
<p class="px-4 mt-4 mb-4 font-medium text-center typography-text-base">
{{ bannerNode.value.bannerTitle }}
</p>
</div>
</div>
</li>
</ul>
</nav>
<!-- Mobile drawer -->
<div v-if="isOpen" class="md:hidden fixed inset-0 bg-neutral-500 bg-opacity-50" />
<SfDrawer ref="drawerRef" v-model="isOpen" placement="left" class="md:hidden bg-white w-[320px] overflow-y-auto">
<nav>
<div class="flex items-center justify-between p-4 border-b border-b-neutral-200 border-b-solid">
<p class="typography-text-base font-medium">Browse products</p>
<SfButton variant="tertiary" square aria-label="Close menu" class="-m-2" @click="close()">
<SfIconClose class="text-neutral-500" />
</SfButton>
</div>
<ul class="mt-2 mb-6">
<li v-if="activeMenu.key !== 'root'">
<SfListItem
size="lg"
tag="button"
type="button"
class="border-b border-b-neutral-200 border-b-solid"
@click="goBack()"
>
<div class="flex items-center">
<SfIconArrowBack class="text-neutral-500" />
<p class="ml-5 font-medium">{{ activeMenu.value.label }}</p>
</div>
</SfListItem>
</li>
<template v-for="node in activeMenu.children" :key="node.value.label">
<li v-if="node.isLeaf">
<SfListItem size="lg" tag="a" :href="node.value.link" class="first-of-type:mt-2">
<div class="flex items-center">
<p class="text-left">{{ node.value.label }}</p>
<SfCounter class="ml-2">{{ node.value.counter }}</SfCounter>
</div>
</SfListItem>
</li>
<li v-else>
<SfListItem size="lg" tag="button" type="button" @click="goNext(node.key)">
<div class="flex justify-between items-center">
<div class="flex items-center">
<p class="text-left">{{ node.value.label }}</p>
<SfCounter class="ml-2">{{ node.value.counter }}</SfCounter>
</div>
<SfIconChevronRight class="text-neutral-500" />
</div>
</SfListItem>
</li>
</template>
</ul>
<div
v-if="bannerNode.value.banner"
class="flex items-center overflow-hidden bg-neutral-100 border-neutral-300 grow"
>
<img
:src="bannerNode.value.banner"
:alt="bannerNode.value.bannerTitle"
class="object-contain w-[50%] basis-6/12"
/>
<p class="basis-6/12 p-6 font-medium typography-text-base">{{ bannerNode.value.bannerTitle }}</p>
</div>
</nav>
</SfDrawer>
</header>
</div>
</template>
<script lang="ts" setup>
import {
SfIconShoppingCart,
SfIconFavorite,
SfIconPerson,
SfIconClose,
SfButton,
SfDrawer,
SfListItem,
SfIconChevronRight,
SfIconMenu,
SfCounter,
SfIconArrowBack,
useDisclosure,
useTrapFocus,
useDropdown,
} from '@storefront-ui/vue';
import { ref, computed } from 'vue';
import { unrefElement } from '@vueuse/core';
const findNode = (keys: string[], node: Node): Node => {
if (keys.length > 1) {
const [currentKey, ...restKeys] = keys;
return findNode(restKeys, node.children?.find((child) => child.key === currentKey) || node);
} else {
return node.children?.find((child) => child.key === keys[0]) || node;
}
};
const { close, open, isOpen } = useDisclosure();
const { referenceRef, floatingRef, style } = useDropdown({
isOpen,
onClose: close,
placement: 'bottom-start',
middleware: [],
});
const drawerRef = ref();
const megaMenuRef = ref();
const triggerRefs = ref();
const activeNode = ref<string[]>([]);
const activeMenu = computed(() => findNode(activeNode.value, content));
const bannerNode = computed(() => findNode(activeNode.value.slice(0, 1), content));
const trapFocusOptions = {
activeState: isOpen,
arrowKeysUpDown: true,
initialFocus: 'container',
} as const;
useTrapFocus(
computed(() => megaMenuRef.value?.[0]),
trapFocusOptions,
);
useTrapFocus(drawerRef, trapFocusOptions);
const openMenu = (menuType: string[]) => {
activeNode.value = menuType;
open();
};
const goBack = () => {
activeNode.value = activeNode.value.slice(0, activeNode.value.length - 1);
};
const goNext = (key: string) => {
activeNode.value = [...activeNode.value, key];
};
const focusTrigger = (index: number) => {
unrefElement(triggerRefs.value[index]).focus();
};
const actionItems = [
{
icon: SfIconShoppingCart,
label: '',
ariaLabel: 'Cart',
role: 'button',
},
{
icon: SfIconFavorite,
label: '',
ariaLabel: 'Wishlist',
role: 'button',
},
{
icon: SfIconPerson,
label: 'Log in',
ariaLabel: 'Log in',
role: 'login',
},
];
type Node = {
key: string;
value: {
label: string;
counter: number;
link?: string;
banner?: string;
bannerTitle?: string;
};
children?: Node[];
isLeaf: boolean;
};
const content: Node = {
key: 'root',
value: { label: '', counter: 0 },
isLeaf: false,
children: [
{
key: 'WOMEN',
value: {
label: 'Women',
counter: 515,
banner: 'https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/glasses.png',
bannerTitle: 'The world in a new light',
},
isLeaf: false,
children: [
{
key: 'ALL_WOMEN',
value: { label: "All Women's", counter: 515, link: '#' },
isLeaf: true,
},
{
key: 'CATEGORIES',
value: { label: 'Categories', counter: 178 },
isLeaf: false,
children: [
{
key: 'ALL_CATEGORIES',
value: { label: 'All Categories', counter: 178, link: '#' },
isLeaf: true,
},
{
key: 'CLOTHING',
value: { label: 'Clothing', counter: 30, link: '#' },
isLeaf: true,
},
{
key: 'SHOES',
value: { label: 'Shoes', counter: 28, link: '#' },
isLeaf: true,
},
{
key: 'ACCESSORIES',
value: { label: 'Accessories', counter: 56, link: '#' },
isLeaf: true,
},
{
key: 'WEARABLES',
value: { label: 'Wearables', counter: 12, link: '#' },
isLeaf: true,
},
{
key: 'FOOD_DRINKS',
value: { label: 'Food & Drinks', counter: 52, link: '#' },
isLeaf: true,
},
],
},
{
key: 'ACTIVITIES',
value: { label: 'Activities', counter: 239 },
isLeaf: false,
children: [
{
key: 'ALL_ACTIVITIES',
value: { label: 'All Activities', counter: 239, link: '#' },
isLeaf: true,
},
{
key: 'FITNESS',
value: { label: 'Fitness', counter: 83, link: '#' },
isLeaf: true,
},
{
key: 'PILATES',
value: { label: 'Pilates', counter: 65, link: '#' },
isLeaf: true,
},
{
key: 'TRAINING',
value: { label: 'Training', counter: 21, link: '#' },
isLeaf: true,
},
{
key: 'CARDIO_WORKOUT',
value: { label: 'Cardio Workout', counter: 50, link: '#' },
isLeaf: true,
},
{
key: 'YOGA',
value: { label: 'Yoga', counter: 20, link: '#' },
isLeaf: true,
},
],
},
{
key: 'DEALS',
value: { label: 'Deals', counter: 98 },
isLeaf: false,
children: [
{
key: 'ALL_DEALS',
value: { label: 'All Deals', counter: 98, link: '#' },
isLeaf: true,
},
{
key: 'OUTLET',
value: { label: 'Outlet', counter: 98, link: '#' },
isLeaf: true,
},
],
},
],
},
{
key: 'MEN',
value: {
label: 'Men',
counter: 364,
banner: 'https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/watch.png',
bannerTitle: 'New in designer watches',
},
isLeaf: false,
children: [
{
key: 'ALL_MEN',
value: { label: "All Men's", counter: 364, link: '#' },
isLeaf: true,
},
{
key: 'CATEGORIES',
value: { label: 'Categories', counter: 164 },
isLeaf: false,
children: [
{
key: 'ALL_CATEGORIES',
value: { label: 'All Categories', counter: 164, link: '#' },
isLeaf: true,
},
{
key: 'CLOTHING',
value: { label: 'Clothing', counter: 41, link: '#' },
isLeaf: true,
},
{
key: 'SHOES',
value: { label: 'Shoes', counter: 20, link: '#' },
isLeaf: true,
},
{
key: 'ACCESSORIES',
value: { label: 'Accessories', counter: 56, link: '#' },
isLeaf: true,
},
{
key: 'WEARABLES',
value: { label: 'Wearables', counter: 32, link: '#' },
isLeaf: true,
},
{
key: 'FOOD_DRINKS',
value: { label: 'Food & Drinks', counter: 15, link: '#' },
isLeaf: true,
},
],
},
{
key: 'ACTIVITIES',
value: { label: 'Activities', counter: 132 },
isLeaf: false,
children: [
{
key: 'ALL_ACTIVITIES',
value: { label: 'All Activities', counter: 132, link: '#' },
isLeaf: true,
},
{
key: 'TRAINING',
value: { label: 'Training', counter: 21, link: '#' },
isLeaf: true,
},
{
key: 'WORKOUT',
value: { label: 'Workout', counter: 43, link: '#' },
isLeaf: true,
},
{
key: 'FOOTBALL',
value: { label: 'Football', counter: 30, link: '#' },
isLeaf: true,
},
{
key: 'FITNESS',
value: { label: 'Fitness', counter: 38, link: '#' },
isLeaf: true,
},
],
},
{
key: 'DEALS',
value: { label: 'Deals', counter: 68 },
isLeaf: false,
children: [
{
key: 'ALL_DEALS',
value: { label: 'All Deals', counter: 68, link: '#' },
isLeaf: true,
},
{
key: 'OUTLET',
value: { label: 'Outlet', counter: 68, link: '#' },
isLeaf: true,
},
],
},
],
},
{
key: 'KIDS',
value: {
label: 'Kids',
counter: 263,
banner: 'https://storage.googleapis.com/sfui_docs_artifacts_bucket_public/production/toy.png',
bannerTitle: 'Unleash your imagination',
},
isLeaf: false,
children: [
{
key: 'ALL_KIDS',
value: { label: 'All Kids', counter: 263, link: '#' },
isLeaf: true,
},
{
key: 'CATEGORIES',
value: { label: 'Categories', counter: 192 },
isLeaf: false,
children: [
{
key: 'ALL_CATEGORIES',
value: { label: 'All Categories', counter: 192, link: '#' },
isLeaf: true,
},
{
key: 'CLOTHING',
value: { label: 'Clothing', counter: 29, link: '#' },
isLeaf: true,
},
{
key: 'SHOES',
value: { label: 'Shoes', counter: 60, link: '#' },
isLeaf: true,
},
{
key: 'ACCESSORIES',
value: { label: 'Accessories', counter: 48, link: '#' },
isLeaf: true,
},
{
key: 'WEARABLES',
value: { label: 'Wearables', counter: 22, link: '#' },
isLeaf: true,
},
{
key: 'FOOD_DRINKS',
value: { label: 'Food & Drinks', counter: 33, link: '#' },
isLeaf: true,
},
],
},
{
key: 'ACTIVITIES',
value: { label: 'Activities', counter: 40 },
isLeaf: false,
children: [
{
key: 'ALL_ACTIVITIES',
value: { label: 'All Activities', counter: 40, link: '#' },
isLeaf: true,
},
{
key: 'FOOTBALL',
value: { label: 'Football', counter: 21, link: '#' },
isLeaf: true,
},
{
key: 'BASKETBALL',
value: { label: 'Basketball', counter: 19, link: '#' },
isLeaf: true,
},
],
},
{
key: 'DEALS',
value: { label: 'Deals', counter: 31 },
isLeaf: false,
children: [
{
key: 'ALL_DEALS',
value: { label: 'All Deals', counter: 31, link: '#' },
isLeaf: true,
},
{
key: 'OUTLET',
value: { label: 'Outlet', counter: 31, link: '#' },
isLeaf: true,
},
],
},
],
},
],
};
</script>