
Declarative Dialog Menu with Invoker Commands
Declarative Dialog Menu with Invoker Commands êŽë š
The off-canvas menu â aka the Hamburger, if you must â has been hot ever since Jobsâ invented mobile web and Ethan Marcott put a name to responsive design.
My journey
Making an off-canvas menu free from heinous JavaScript has always been possible, but not ideal. I wrote up one technique for Smashing Magazine in 2013. Later I explored <dialog> in an absurdly titled post where I used the new Popover API[1]
Sources on 'Popover API'
Current thoughts
I strongly push clients towards a simple, always visible, flex-box-wrapping list of links. Not least because leaving the subject unattended leads to a multi-level monstrosity.
I also believe that good design and content strategy should allow users to navigate and complete primary goals without touching the âmain menuâ. However, I concede that Hamburgers are now mainstream UI. Jason Bradberry makes a compelling case.
My new menu
This month I redesigned my website. Taking the menu off-canvas at all breakpoints was a painful decision. Iâm still not at peace with it. I donât like plain icons. To somewhat appease my anguish I added big bold âMenuâ text.
The HTML for the button is pure declarative goodness.
<button type="button" commandfor="menu" command="show-modal">
<span class="visually-hidden">open</span> Menu
</button>
Accessibility updates
I originally added the extra âopenâ for clarity. It was noted that prefixes can cause issues (@therealkimblim) for voice control and that my addition is unnecessary anyway. I removed that from my live site. It was also noted there was no navigation landmark (sarasoueidan.com) on the page. This can be solved by wrapping the <button> in a <nav> element, which I have now done. Thanks for the feedback!
Aside note
Ana Tudor asked (anatudor.bsky.social) do we still need all those âvisually hiddenâ styles? Iâm using them out of an abundance of caution but my feeling is that Ana is on to something.
The menu HTML is just as clean.
<dialog id="menu">
<h2 class="hidden">Menu</h2>
<button type="button" commandfor="menu" command="close">
Close <span class="visually-hidden">menu</span>
</button>
<nav>
<ul>
<li><a href="/" aria-current="page">Home</a></li>
<li><a href="/services/">Services</a></li>
<li><a href="/about/">About</a></li>
<li><a href="/blog/">Blog</a></li>
<li><a href="/notes/">Notes</a></li>
<li><a href="/contact/">Contact</a></li>
</ul>
</nav>
</dialog>
Itâs that simple! Iâve only removed my opinionated class names I use to draw the rest of the owl. Iâll explain more of my style choices later.
This technique uses the wonderful new Invoker Command API[2]
Sources on 'Invoker Command'
for interactivity. It is similar to the popover I mentioned earlier. With a real <dialog> we get free focus management and more, as Chris Coyier explains. I made a basic CodePen demo (dbushell) for the code above.
The JavaScript
So hereâs the bad news. Invoker commands are so new they must be polyfilled for old browsers. Good news; you donât need a hefty script. Feature detection isnât strictly necessary.
const $menu = document.querySelector("#menu");
for (const $button of document.querySelectorAll('[commandfor="menu"]')) {
$button.addEventListener("click", (ev) => {
ev.preventDefault();
if ($menu.open) $menu.close();
else $menu.showModal();
});
}
Keith Cirkel has a more extensive polyfill (keithamus/invokers-polyfill) if you need full API coverage like JavaScript events. My basic version overrides the declarative API with the JavaScript API for one specific use case, and the behaviour remains the same.
WebKit focus, visible?
Letâs get into CSS by starting with my favourite:
:focus-visible {
outline: 2px solid magenta;
outline-offset: 2px;
}
A strong contrast outline around buttons and links with room to breath. This is not typically visible for pointer events. For other interactions like keyboard navigation itâs visible.
The first button inside the dialog, i.e. âClose (menu)â, is naturally given focus by the browser (focus is âtrappedâ inside the dialog). In most browsers focus remains invisible for pointer events. WebKit has bug. When using showModal or invoker commands the focus-visible style is visible on the close button for pointer events. This seems wrong, itâs inconsistent, and clients absolutely rage at seeing âuglyâ focus â seriously, what is their problem?!
I think Iâve found a reliable âfixâ. Please do not copy this untested. From my limited testing with Apple devices and macOS VoiceOver I found no adverse effects. Below Iâve expanded the ânot openâ condition within the event listener.
if ($menu.open) {
$menu.close();
} else {
$menu.showModal();
if (ev.pointerId > 0) {
const $active = document.activeElement;
if ($active.matches(":focus-visible")) {
$active.blur();
$active.focus({ focusVisible: false });
}
}
}
First I confirm the event is relevant. I canât check for an instance of PointerEvent because of the click handler. Iâd have to listen for keyboard events and that gets murky. Then I check if the focused element has the visible style. If both conditions are true, I remove and reapply focus in a non-visible manner. The focusVisible boolean is Safari 18.4 onwards.
Warning
Like I said: extreme caution! But I believe this fixes WebKitâs inconsistency. Feedback is very welcome. Iâll update here if concerns are raised.
Click to dimiss
Native dialog elements allow us to press the ESC key to dismiss them. What about clicking the backdrop? We must opt-in to this behaviour with the closedby="any" attribute. Chris Ferdinandi has written about this and the JavaScript fallback.
Thatâs enough JavaScript!
Fancy styles
My menu uses a combination of both basic CSS transitions and cross-document view transitions[3]
. For on-page transitions I use the setup below.
#menu {
opacity: 0;
transition:
opacity 300ms,
display 300ms allow-discrete,
overlay 300ms allow-discrete;
&[open] {
opacity: 1;
}
}
@starting-style {
#menu[open] {
opacity: 0;
}
}
As an example here I fade opacity in and out. How you choose to use nesting selectors and the @starting-style rule is a matter of taste. I like my at-rules top level.
My menu also transitions out when a link is clicked. This does not trigger the closing dialog event. Instead the closing transition is mirrored by a cross-document view transition.
The example below handles the fade out for page transitions.
@view-transition {
navigation: auto;
}
#menu {
view-transition-name: --menu;
}
@keyframes --menu-old {
from { opacity: 1; }
to { opacity: 0; }
}
::view-transition-old(--menu) {
animation: --menu-old 300ms ease-out forwards;
}
Note that I only transition the old view state for the closing menu. The new state is hidden (âoff-canvasâ). Technically it should be possible to use view transitions to achieve the on-page open and close effects too. Iâve personally found browsers to still be a little janky around view transitions â bugs, or skill issue?
Itâs probably best to wrap a media query around transitions.
@media not (prefers-reduced-motion: reduce) {
/* fancy pants transitions */
}
âReducedâ is a significant word. It does not mean âno motionâ. That said, I have no idea how to assess what is adequately reduced! No motion is a safe bet⊠I think?
So there we have it! Declarative dialog menu with invoker commands, topped with a medley of CSS transitions and a sprinkle of almost optional JavaScript. Arenât modern web standards wonderful, when they work?
Info
I canât end this topic without mentioning Jim Nielsenâs menu. I wonât spoil the fun, take a look! When I realised how it works, my first reaction was âis that allowed?!â It workâs remarkably well for Jimâs blog. I donât recall seeing that idea in the wild elsewhere.
A mechanism for top-layer accessible components. Popovers can be implemented declaratively in HTML (yay!) or with JavaScript (boo!) â©ïž
Declarative HTML attributes that add interactive behaviour without JavaScript. Thatâs cheating! â©ïž
Yet another web standard API for animations and transitions. Theyâve become exceeding efficient at it. â©ïž