Written by Mark Conroy
✏️
An off-canvas menu is a common pattern in web design. You see it often on mobile websites where you click on a hamburger menu button and the menu slides in from the side of the screen, usually covering the content that's behind it.
Building on the overview of off-canvas menus, this article will explore the steps of creating an accessible off-canvas menu using web components and the <dialog>
element. We will explore how to integrate this menu into your website, ensuring it not only enhances user experience but also adheres to accessibility standards.
Accessibility considerations for off-canvas menus
While creating an off-canvas menu is a fairly easy task to complete superficially, it can become tricky when you consider accessibility. You must consider things such as:
- An ARIA attribute to announce that the item is
expanded
- A focus trap inside the off-canvas element so users don’t accidentally tab outside to links they can’t see
- A button to close the off-canvas element (and after that, reset any attributes back to their defaults)
- The focus state coming back to the item that triggered the opening of the off-canvas element
When it comes to accessibility, the first rule is to use the browser’s native APIs. In this case, we’ll use the <dialog>
element.
This will allow us to show the contents of the off-canvas element when we click a button, provide a focus trap for free, and announce whatever needs to be announced to assistive technologies. It also means that we don’t have to maintain or update any of these features ourselves. The next section will offer a short primer on how to create a dialog box.
HTML for a <dialog>
element
For the HTML, it’s as simple as adding a <dialog>
element to the page, adding a button that we can use to open the dialog box, and finally adding a button inside the box that we can use to close it:
<html>
<head></head>
<body>
All your HTML here
<button class="open-dialog">Open Dialog</dialog>
<dialog>
<button class="close-dialog">Close Dialog</dialog>
</dialog>
</body>
</html>
N.B., By default, you can hit the ESC
key to close the dialog if there is no close button present.
JavaScript for a <dialog>
element
The JavaScript is also quite simple. We create variables for the dialog box, the open button, and the close button. Then, we add an event listener to each of the buttons to call the showModal()
or close()
methods on the dialog:
const dialog = document.querySelector('dialog');
const buttonOpen = document.querySelector('.open-dialog')
const buttonClose = document.querySelector('.close-dialog')
buttonOpen.addEventListener('click', function() {
dialog.showModal()
});
buttonClose.addEventListener('click', function() {
dialog.close()
});
You can see a demo of this basic dialog element on CodePen.
Now that our <dialog>
element has been created, let's click the button and see what happens. Right now, the dialog is shown in the center of the screen. This is expected behavior, as when we usually think of a dialog, we think of a modal pop-up feature that shows a larger version of an image.
However, for our menu, we want to show it animating in from the side of the screen. So let's add some CSS to position it off-screen.
Adding CSS to position the dialog off-canvas
.dialog-menu,
.dialog-menu[open] {
position: fixed;
width: 400px;
max-width: 80%;
min-height: 100vh;
margin: 0;
margin-left: auto;
transform: translateX(100%);
transition: .3s;
}
There are a couple of interesting things to note here about the CSS. By default, when visible, a dialog is centered horizontally and vertically just like it would be if you used display: flex
on the container and margin: auto
on the item itself.
To stop it from being centered in the screen, we set margin: 0
for all sides, and then margin-left: auto
. This ensures that the dialog is positioned as far to the right of the screen as possible. We then use position: fixed
and transform: translateX(100%)
to move the dialog off the screen by the same value as its width (100% of its x/width).
We need the position: fixed
here or else we will have a horizontal scroll of the same value as the dialog’s width. (N.B., We could use overflow-x: hidden
on the body element, but that is a bit too drastic and can have unintended consequences).
The width is set to 400px, but constrained by the max width being 80%. This ensures that when the dialog is visible, it won’t cover our entire screen, so users are not wondering where the content has gone!
Next, for the height of the item, we set it to a minimum of 100% of the height of the screen, so if there are many menu items in it, we will be able to scroll to see them, but if there are only a few, the dialog will still look good.
For a nice UX, we set the transition: 0.3s
to create an animated effect for the dialog coming onto and off the screen. However, it has no effect yet, because dialogs being shown or hidden change state from display: none
to display: block
and you can’t transition from these two states. But don’t worry, we have a solution coming for you!
CSS for when the dialog is on the screen
.dialog-menu[open] {
display: flex;
margin: 0;
margin-left: auto;
flex-direction: column;
transform: translateX(0);
transition: .3s;
}
.close-dialog {
margin-left: auto;
}
When a dialog box is visible, it automatically gets an attribute of open
. We’ll use this attribute for our styling when the modal is visible.
We need to add the margin: 0
and margin-left: auto
here again, as it gets reset when the dialog
is open
. We set our display to display: flex
rather than the display: block
that the browser sets to it. I’m using flex-direction: column
next, and then setting the .close-dialog
button to margin-left: auto
so that it is positioned on the right side of the dialog (you might prefer to use grid or some other mechanism).
You’ll remember we set our transform: translateX
to -100% to position it off-screen. Now, to have it on screen, we reset that back to transform: translateX(0)
and add our transition time back in so it will animate out when closing. Yes, I know, the animation is still not working — we’ll get to that next:
See the CodePen for a basic off-canvas dialog.
Animating an off-canvas dialog
We can’t animate something from display: none
to display: block
and vice versa. To get around this, we will take the following steps when we click on the button to open the dialog
:
- Set
<dialog>
todisplay: flex
, so now we can animate it - Use a
setTimeout
for a tiny amount of time, and then call theshowModal()
method, which adds the[open]
attribute. Now, because the dialog’s display property is atdisplay: flex
before we start the animation, our transition will work - Apply the CSS that is scoped to the
[open]
attribute to animate the dialog into view
const dialogMenu = document.querySelector(".dialog-menu");
const menuToggle = document.querySelector(".menu-toggle");
menuToggle.addEventListener("click", () => {
dialogMenu.style.display = "flex";
setTimeout(() => {
dialogMenu.showModal();
}, 100);
});
When we want to close the off-canvas menu, we take a similar approach.
First, we add a class of .dialog-menu--closing
to the dialog. We can then use this class to run the animation of sending the dialog back offscreen. We can just add this class to the same CSS we have for the default .dialog-menu
above.
Next, we use another setTimeout
with a very short amount of time, and use that to:
- Close the dialog via the
.close()
menu - Set the dialog back to
display: none
, and - Finally, remove the
dialog-menu--closing
class that we added because the animation has now ended
const dialogMenu = document.querySelector(".dialog-menu");
const dialogMenuCloseButton = document.querySelector(".dialog__menu-close");
const menuToggle = document.querySelector(".menu-toggle");
dialogMenuCloseButton.addEventListener('click', () => {
dialogMenu.classList.add("dialog-menu--closing");
setTimeout(() => {
dialogMenu.close();
dialogMenu.style.display = "none";
dialogMenu.classList.remove("dialog-menu--closing");
}, 100);
}
.dialog-menu,
.dialog-menu[open].dialog-menu--closing {
/* Same CSS as for .dialog-menu above */
}
We now have a nice-looking off-canvas dialog:
See the CodePen for an animated off-canvas dialog.
Creating a menu as a web component
In this section, we are going to use a web component to create a menu because web components are ideal for creating reusable HTML elements. Importantly, it allows for a single update to the menu to automatically circulate and apply throughout the entire site so that we only have to update our menu once. We could do the same for a reusable header and footer.
Our web component is a simple component that creates a nav
element and puts an unordered list inside it with the links for our menu. We attach this to the shadow DOM, and add some CSS:
class Menu extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
ul {
list-style: none;
margin: 0;
padding: 0;
}
li {
margin-top: var(--spacing);
}
li + li {
border-top: 1px solid white;
}
a {
display: block;
padding-block: 1rem;
text-decoration: none;
color: white;
}
</style>
<nav>
<ul>
<li><a href="https://example.com/about">About Us</a></li>
<li><a href="https://example.com/services">Our Services</a></li>
<li><a href="https://example.com/testimonials">Testimonials</a></li>
<li><a href="https://example.com/location">Directions</a></li>
<li><a href="https://example.com/contact">Contact Us</a></li>
</ul>
</nav>
`;
}
}
customElements.define("custom-menu", Menu);
Now that we have our web component created and our dialog created and working, we just need to place our web component inside our <dialog>
element:
<dialog class="dialog-menu">
<button class="close-dialog">Close Dialog</button>
<custom-menu></custom-menu>
</dialog>
Here’s the full demo:
See the CodePen for an animated off-canvas menu created with a web component.
What have we learned?
In this post, we learned how to use native HTML tags and web APIs to ensure future-proofing in our website. We also learned that the <dialog>
element is not just for creating modals, but that we can animate it from anywhere and to anywhere on our site if we’re creative with our approach. Finally, we covered creating web components to be able to create a component once and use it everywhere across our webpage.
Get set up with LogRocket's modern error tracking in minutes:
- Visit https://logrocket.com/signup/ to get an app ID.
- Install LogRocket via NPM or script tag.
LogRocket.init()
must be called client-side, not server-side.
NPM:
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
Script Tag:
Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
3.(Optional) Install plugins for deeper integrations with your stack:
- Redux middleware
- ngrx middleware
- Vuex plugin
Top comments (2)
Thanks for this
Hi Megan Lee,
Excellent content, very useful.
Thanks for sharing.