Building a WordPress Mega Menu Block for FSE Themes
When we built the Elayne theme, we needed a mega menu that felt native to Full Site Editing (FSE): template-part driven, fast, and editor-friendly. Most plugins we tested were either legacy (shortcodes/widgets) or heavy on front-end JavaScript. So we built our own mega menu block in the open-source Elayne Blocks plugin and shipped it on production sites, including imagewize.com.
This post is a technical deep dive into how the block works, why we made certain decisions, and the practical details you only learn after building one for real users.
What makes a mega menu hard in FSE
A mega menu is not just a dropdown. It has to behave like a navigation system and a mini layout engine at the same time. Here were our real-world requirements:
- Works inside the core Navigation block in FSE themes.
- Supports complex layouts (multi-column, images, CTA blocks) without hard-coding a schema.
- Has lightweight interactions and no front-end framework requirement.
- Handles hover and click behaviors without breaking accessibility.
- Performs well on mobile with full-screen overlays and scroll-safe content.
The biggest hurdle was letting editors build content freely while keeping layout and behavior consistent across desktop and mobile.
Architecture overview
The block is split into three layers:
- Editor UI (React) for configuration and previews.
- Server-side render (PHP) for HTML output and template-part integration.
- Front-end behavior (Interactivity API) for open/close logic, focus management, and responsive adjustments.
Each layer is intentionally small and focused so the block stays maintainable.
Editor UI: template parts, layout modes, and controls
The editor experience is built around template parts. Instead of forcing menu items into a fixed schema, we let editors select a template part and compose the mega menu content with any blocks they want.
Source: blocks/mega-menu/src/edit.js (lines 76-206)
const { hasResolved, records } = useEntityRecords('postType', 'wp_template_part', {
per_page: -1,
});
const menuOptions = hasResolved
? records
.filter((item) => item.area === 'menu' || item.area === 'elayne-mega-menu')
.map((item) => ({ label: item.title.rendered, value: item.slug }))
: [];
<ComboboxControl
label={__('Select Menu Template', 'elayne-blocks')}
value={menuSlug}
options={menuOptions}
onChange={(value) => setAttributes({ menuSlug: value })}
/>
Why template parts?
- They’re theme-scoped, so the design stays consistent with the active FSE theme.
- Editors can use any block inside the menu without custom tooling.
- The menu content is stored in the database and can be updated without redeploying code.
Layout modes (Dropdown and Overlay)
We ship two layout modes: a classic dropdown and a full-screen overlay. The layout picker is a visual control, not a text dropdown, to help editors choose quickly.
Source: blocks/mega-menu/src/components/LayoutPicker.jsx (lines 14-62)
const layouts = [
{ value: 'dropdown', label: __('Dropdown', 'elayne') },
{ value: 'overlay', label: __('Overlay', 'elayne') },
];
<ButtonGroup className="layout-picker__buttons">
{layouts.map((layout) => (
<Button
isPressed={value === layout.value}
onClick={() => onChange(layout.value)}
>
<span>{layout.label}</span>
</Button>
))}
</ButtonGroup>
The editor exposes layout-specific controls (alignment, full width, overlay backdrop color), which map directly to server-side classes and inline styles.
Icon picker and animation controls
We built custom controls so editors can add icons and tweak animations without dealing with raw CSS.
Source: blocks/mega-menu/src/components/IconPicker.jsx (lines 21-193)
const filteredIcons = DASHICONS.filter(
(icon) =>
icon.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
icon.label.toLowerCase().includes(searchTerm.toLowerCase())
);
{filteredIcons.map((icon) => (
<Button
key={icon.name}
variant={value === icon.name ? 'primary' : 'secondary'}
onClick={() => onChange(icon.name)}
>
<span className={`dashicons dashicons-${icon.name}`} />
</Button>
))}
Source: blocks/mega-menu/src/components/AnimationControls.jsx (lines 17-183)
const ANIMATION_TYPES = [
{ value: 'fade', label: __('Fade', 'elayne-blocks') },
{ value: 'slide', label: __('Slide', 'elayne-blocks') },
{ value: 'scale', label: __('Scale', 'elayne-blocks') },
{ value: 'slidefade', label: __('Slide + Fade', 'elayne-blocks') },
];
<RangeControl
value={animationSpeed}
onChange={onSpeedChange}
min={100}
max={1000}
step={50}
help={`${animationSpeed}ms`}
withInputField={false}
/>
These controls are not just UI polish; they reduce the need for custom CSS and keep editor behavior consistent with front-end output.
Server-side rendering: reliable HTML output
The mega menu renders via PHP so the menu links and content ship in the initial HTML. That keeps the menu fast and crawlable and avoids hydration complexity.
Source: blocks/mega-menu/src/render.php (lines 63-192, simplified)
$elayne_blocks_context = array(
'isOpen' => false,
'layoutMode' => $elayne_blocks_layout_mode,
'dropdownAlignment' => $elayne_blocks_dropdown_alignment,
'animationSpeed' => $elayne_blocks_animation_speed,
'mobileBreakpoint' => absint( $attributes['mobileBreakpoint'] ?? 768 ),
);
<li
data-wp-interactive="elayne/mega-menu"
data-wp-context='<?php echo esc_attr( wp_json_encode( $elayne_blocks_context ) ); ?>'
>
<button class="wp-block-elayne-mega-menu__trigger" data-wp-on--click="actions.toggleMenu">
<?php echo $elayne_blocks_button_content; ?>
</button>
<div class="wp-block-elayne-mega-menu__panel" data-wp-class--is-open="context.isOpen">
<?php echo block_template_part( $elayne_blocks_menu_slug ); ?>
</div>
</li>
Key decisions here:
- We store state in the Interactivity API context, not in data attributes.
- Template parts are rendered server-side so the menu is real HTML (no client-side rendering).
- We output layout classes and inline styles for spacing, max width, and border settings.
Front-end interactions: Interactivity API + focus management
The front-end behavior is implemented with the WordPress Interactivity API, not React. The code handles open/close, focus trapping (overlay mode), and full-width positioning when the panel needs to align with the nav container.
Source: blocks/mega-menu/src/view.js (lines 21-218)
const { state, actions } = store('elayne/mega-menu', {
actions: {
openMenu() {
const context = getContext();
if (context.layoutMode === 'overlay') {
document.body.classList.add('mega-menu-overlay-open');
}
actions.positionFullWidthPanel();
context.isOpen = true;
actions.setFocusTrap();
},
closeMenu() {
const context = getContext();
context.isOpen = false;
document.body.classList.remove('mega-menu-overlay-open');
actions.returnFocus();
},
},
});
We also attach document-level listeners for ESC and outside clicks (via data-wp-on-document--keydown and data-wp-on-document--click in render.php) to keep the interaction consistent no matter where the menu is used.
Layout and responsive behavior
The layout system is CSS-first, with JS only needed for full-width panel alignment. Dropdowns use alignment classes (align-left, align-right, align-center) so we can avoid runtime calculations for most cases.
Source: blocks/mega-menu/src/style.scss (lines 411-456)
.wp-block-elayne-mega-menu--layout-dropdown {
.wp-block-elayne-mega-menu__panel {
top: calc(100% + var(--mm-dropdown-spacing, 16px));
min-width: min(var(--mm-dropdown-max-width, 600px), calc(100vw - 40px));
max-width: calc(100vw - 40px);
&.align-left { left: 0; }
&.align-right { right: 0; left: auto; }
&.align-center { left: 50%; transform: translateX(-50%) !important; }
}
}
On mobile, dropdowns become full-screen panels to prevent overflow and ensure scrolling works predictably.
Source: blocks/mega-menu/src/style.scss (lines 536-563)
@media (max-width: 768px) {
.wp-block-elayne-mega-menu__panel {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100vh !important;
overflow-y: auto !important;
}
.menu-container__close-button {
position: fixed !important;
top: 20px !important;
right: 20px !important;
}
}
This full-screen pattern was driven by a real production issue. We documented the investigation on January 18, 2026, and shipped a fix on January 21, 2026 (commit 0c60ecf). The fix ensured the panel and close button are positioned reliably across mobile breakpoints.
Accessibility details that matter
Small details make a big difference in navigation UX:
aria-expandedis bound tocontext.isOpenfor accurate state.- Focus is trapped inside the overlay in overlay mode.
- Focus returns to the trigger on close.
- Screen-reader descriptions are optional via the block settings.
The focus-trap behavior lives in view.js and is tied to the overlay layout to avoid accidental tab loops in dropdown mode.
Lessons learned from production
- Manual alignment beats auto-positioning. After multiple attempts to auto-correct overflow, we standardized on alignment controls that editors can set. It is more predictable and easier to test.
- Make mobile behavior explicit. Full-screen panels with fixed close buttons are more resilient than trying to squeeze a mega menu into a narrow dropdown.
- Keep front-end JS thin. The Interactivity API is a good fit for stateful UI without pulling in a framework.
- Test at real viewport sizes. Our early test reports were done at wide desktop sizes and missed failures at 1280px and 1440px.
Final thoughts
Building a production mega menu block required coordination across React, PHP rendering, and front-end interactivity. The payoff is an editor experience that feels native to FSE and a front-end menu that is fast, accessible, and reliable on real devices.
The complete source code is available in the Elayne Blocks plugin on GitHub. The mega menu block is in blocks/mega-menu/ with full documentation and test reports.
We are here to help you. Let’s talk.
Went through it all here at Imagewize and curious? You have questions? A possible project you would like to discuss with us? Do now hesitate to hit us up!