Nested menu
Open the live nested menu demo
Live demo
Nested menu
Source for this preview
---import { NESTED_MENU_PROJECT_LABELS, NESTED_MENU_ROOT_LABELS,} from '../../example-data';---
<article id="nested-menu-demo" class="demo-card nested-menu-card"> <div class="card-top"> <span class="number">T</span><span class="chip">2 coordinated roots</span> </div> <h3>Nested command tree</h3> <p> Open “Move to project” with click or ArrowRight. Escape returns focus to its parent item. </p> <div class="card-action"> <floating-tree> <floating-root id="nested-menu-root" placement="bottom-start"> <floating-node node-id="actions"> <floating-reference> <button class="dark-button"> Open actions <span aria-hidden="true">⌄</span> </button> </floating-reference> <template slot="content"> <div class="menu-panel nested-menu-root"> <div class="menu-heading">Tree coordinated actions</div> <floating-list navigation typeahead loop item-selector=".root-menu-item" > <button role="menuitem" data-fup-close class="menu-item root-menu-item root-menu-action" aria-label={NESTED_MENU_ROOT_LABELS[0]} > <span>{NESTED_MENU_ROOT_LABELS[0]}</span><kbd>{NESTED_MENU_ROOT_LABELS[0].slice(0, 1).toUpperCase()}</kbd> </button> <floating-root id="nested-menu-projects-root" placement="right-start"> <floating-node node-id="projects"> <floating-reference> <button class="menu-item root-menu-item" role="menuitem" aria-haspopup="menu" aria-label={NESTED_MENU_ROOT_LABELS[1]} > <span>{NESTED_MENU_ROOT_LABELS[1]}</span> <span class="menu-item-shortcuts"><kbd>{NESTED_MENU_ROOT_LABELS[1].slice(0, 1).toUpperCase()}</kbd><kbd aria-hidden="true">→</kbd></span> </button> </floating-reference> <floating-list navigation typeahead loop nested item-selector=".project-menu-item" > <template slot="content"> <div class="menu-panel nested-menu-submenu" aria-label="Move to project" > <div class="menu-heading">Choose a project</div> { NESTED_MENU_PROJECT_LABELS.map((label) => ( <button class="menu-item project-menu-item" role="menuitem" data-fup-close aria-label={label} > <span>{label}</span><kbd>{label.slice(0, 1).toUpperCase()}</kbd> </button> )) } </div> </template> </floating-list> </floating-node> </floating-root> <button class="menu-item root-menu-item root-menu-action" role="menuitem" data-fup-close aria-label={NESTED_MENU_ROOT_LABELS[2]} > <span>{NESTED_MENU_ROOT_LABELS[2]}</span><kbd>{NESTED_MENU_ROOT_LABELS[2].slice(0, 1).toUpperCase()}</kbd> </button> </floating-list> </div> </template> </floating-node> </floating-root> </floating-tree> </div> <code><floating-tree> + <floating-node></code></article>
<script> import { FloatingRootElement, click, dismiss, flip, hover, offset, role, safePolygon, size, shift, transformOrigin, } from '@floating-ui-plus/web-components'; import {initializeExample} from './initialize-example';
initializeExample('nested-menu', (scope) => { const parent = FloatingRootElement.query(scope, '#nested-menu-root'); let child: FloatingRootElement | null = null;
parent.configure({ middleware: [ offset(8), flip({padding: 18}), shift({padding: 18}), size({ padding: 18, rootBoundary: 'viewport', apply({availableHeight, elements}) { elements.floating.style.setProperty( '--menu-panel-max-height', `${Math.max(0, availableHeight)}px`, ); }, }), transformOrigin({padding: 12}), ], plugins: [ click(), dismiss(() => ({ escapeKey: !child?.open, outsidePress: (event) => !(event.target instanceof Element) || !event.target.closest('.nested-menu-submenu'), })), role({role: 'menu'}), ], });
parent.on('floatingmount', ({element}) => { child = FloatingRootElement.query(element, '#nested-menu-projects-root'); child.configure({ middleware: [ offset({mainAxis: 6, alignmentAxis: -6}), flip({padding: 18}), shift({padding: 18}), size({ padding: 18, rootBoundary: 'viewport', apply({availableHeight, elements}) { elements.floating.style.setProperty( '--menu-panel-max-height', `${Math.max(0, availableHeight)}px`, ); }, }), transformOrigin({padding: 12}), ], plugins: [ click(), hover({ move: false, delay: {open: 80, close: 120}, handleClose: safePolygon(), }), dismiss(), role({role: 'menu'}), ], });
child.on('floatingmount', ({element: childElement}) => { childElement.addEventListener('click', (event) => { if (!(event.target instanceof Element)) return; const item = event.target.closest<HTMLElement>( '.project-menu-item', ); if (!item || !childElement.contains(item)) return; parent.close(event, 'click'); }); });
child.on('openchange', ({open, reason}) => { if (!open) { if (reason === 'escape-key' || reason === 'focus-out') { window.setTimeout(() => { const reference = child?.referenceElement; if (reference instanceof HTMLElement) { reference.focus({preventScroll: true}); } }, 0); } } }); });
parent.on('openchange', ({open, reason}) => { if (!open) { if (child) child.open = false; if (reason === 'escape-key') { queueMicrotask(() => { const reference = parent.referenceElement; if (reference instanceof HTMLElement) { reference.focus({preventScroll: true}); } }); } } }); });</script><script setup lang="ts">import { FloatingContent, FloatingList, FloatingListItem, FloatingNode, FloatingReference, FloatingRoot, FloatingTree, autoUpdate, click, dismiss, flip, hover, offset, role, safePolygon, size, shift, transformOrigin,} from '@floating-ui-plus/vue';import {ref, watch} from 'vue';import { NESTED_MENU_PROJECT_LABELS, NESTED_MENU_ROOT_LABELS,} from '../../example-data';
const rootLabels = NESTED_MENU_ROOT_LABELS;const projectLabels = NESTED_MENU_PROJECT_LABELS;const rootOpen = ref(false);const projectsOpen = ref(false);
const rootOptions = { placement: 'bottom-start', middleware: [ offset(8), flip({padding: 18}), shift({padding: 18}), size({ padding: 18, rootBoundary: 'viewport', apply({availableHeight, elements}) { elements.floating.style.setProperty( '--menu-panel-max-height', `${Math.max(0, availableHeight)}px`, ); }, }), transformOrigin({padding: 12}), ], whileElementsMounted: autoUpdate,} as const;const rootPlugins = [ click(), dismiss({ outsidePress: (event) => !(event.target instanceof Element) || !event.target.closest('.vue-submenu'), }), role({role: 'menu'}),];
const projectOptions = { placement: 'right-start', middleware: [ offset({mainAxis: 6, alignmentAxis: -6}), flip({padding: 18}), shift({padding: 18}), size({ padding: 18, rootBoundary: 'viewport', apply({availableHeight, elements}) { elements.floating.style.setProperty( '--menu-panel-max-height', `${Math.max(0, availableHeight)}px`, ); }, }), transformOrigin({padding: 12}), ], whileElementsMounted: autoUpdate,} as const;const projectPlugins = [ click(), hover({ move: false, delay: {open: 80, close: 120}, handleClose: safePolygon(), }), dismiss(), role({role: 'menu'}),];
function handleRootNavigate(index: number | null) { if (index !== 1) projectsOpen.value = false;}
watch(rootOpen, (open) => { if (!open) projectsOpen.value = false;});</script>
<template> <FloatingTree> <FloatingRoot v-model:open="rootOpen" :options="rootOptions" :plugins="rootPlugins" > <FloatingNode id="vue-nested-root"> <article class="vue-demo-card bg-vue-leaf"> <div class="vue-card-top"> <span class="vue-number">T</span><span>2 coordinated roots</span> </div> <h3>Nested command tree</h3> <p> ArrowRight opens the child menu. Escape returns focus to the parent action. </p> <div class="mt-auto pt-7"> <FloatingReference class="vue-button vue-button-ink"> Open actions <span>⌄</span> </FloatingReference> <FloatingList navigation typeahead loop @update:active-index="handleRootNavigate" > <FloatingContent class="menu-panel" id="vue-actions-menu" > <div class="menu-heading">Tree coordinated actions</div> <FloatingListItem tag="button" :label="rootLabels[0]" class="menu-item" role="menuitem" close-on-click > <span>{{ rootLabels[0] }}</span> <kbd>{{ rootLabels[0].slice(0, 1).toUpperCase() }}</kbd> </FloatingListItem>
<FloatingRoot v-model:open="projectsOpen" :options="projectOptions" :plugins="projectPlugins" > <FloatingNode id="vue-nested-projects"> <FloatingListItem reference tag="button" :label="rootLabels[1]" class="menu-item" role="menuitem" aria-haspopup="menu" :aria-expanded="projectsOpen" > <span>{{ rootLabels[1] }}</span> <span class="menu-item-shortcuts"> <kbd>{{ rootLabels[1].slice(0, 1).toUpperCase() }}</kbd> <kbd aria-hidden="true">→</kbd> </span> </FloatingListItem> <FloatingList navigation typeahead loop nested> <FloatingContent class="menu-panel nested-menu-submenu" id="vue-project-menu" > <div class="menu-heading">Choose a project</div> <FloatingListItem v-for="label in projectLabels" :key="label" tag="button" :label="label" class="menu-item" role="menuitem" close-on-click="all" > <span>{{ label }}</span> <kbd>{{ label.slice(0, 1).toUpperCase() }}</kbd> </FloatingListItem> </FloatingContent> </FloatingList> </FloatingNode> </FloatingRoot>
<FloatingListItem tag="button" :label="rootLabels[2]" class="menu-item" role="menuitem" close-on-click > <span>{{ rootLabels[2] }}</span> <kbd>{{ rootLabels[2].slice(0, 1).toUpperCase() }}</kbd> </FloatingListItem> </FloatingContent> </FloatingList> </div> <code><FloatingTree> + <FloatingNode></code> </article> </FloatingNode> </FloatingRoot> </FloatingTree></template>The parent and submenu are separate roots connected through a tree:
<floating-tree> <floating-root> <floating-node node-id="actions"> <floating-reference><button>Open actions</button></floating-reference> <template slot="content"> <floating-list navigation typeahead loop> <floating-root placement="right-start"> <floating-node node-id="projects"> <floating-reference> <button role="menuitem" aria-haspopup="menu">Move to project</button> </floating-reference> <floating-list navigation typeahead loop nested> <!-- project actions --> </floating-list> </floating-node> </floating-root> </floating-list> </template> </floating-node> </floating-root></floating-tree>Click or ArrowRight opens the child. ArrowLeft or Escape closes only the child
and restores focus to the parent item. floating-tree/floating-node carries
the relationship; nested changes the list navigation behavior. The Vue
equivalent uses FloatingTree, FloatingNode, FloatingList, and
FloatingListItem.