Skip to content
llms.txt
llms.txt

Nested menu

Open the live nested menu demo

Live demo

Nested menu

Source for this preview

NestedMenuExample.astro
---
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>&lt;floating-tree&gt; + &lt;floating-node&gt;</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>

Open the full demo ↗

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.