Building an Accessible Modal in Vue.
Drew Clements
Posted on November 15, 2021
Modals are a very common design element across the web today. However, a lot of websites exclude people using assistive-technologies when building their modals. This can lead to very poor and frustrating experiences for those people.
I'll be the first to admit that I have built dozens of these without building in accessible patterns. In fact, in my 2-3 years as a developer, I can say with confidence that only two of those were a11y compliant.
In this article, we're going to look at how to build a reusable and a11y compliant modal component in Vue (Nuxt). Once we're through, you'll be able to take this component/pattern to any of your other projects. This article assumes at least a foundational understanding of Vue.
Setting up the project
We're going to build this example in Nuxt. So, to get things started, we'll run npx create-nuxt-app a11y-tuts
in our terminal to generate a Nuxt project. * Make sure you're in the correct directory where you want your project to live. *
It's going to ask you a few questions about config setups. Set those however you like. Here is how I answered
- Programming Language: Javascript
- Package Manager: Npm
- UI Framework: None (I know, crazy. Right?)
- Nuxt.js Modules: Axios
- Linting Tools: ESLint
- Testing Framework: None
- Rendering Mode: Universal (SSR / SSG)
- Deployment Target: Static (Static/Jamstack hosting)
- Development Tools: jsconfig.json
Now that we have that complete, let's set up a simple scaffold for our app.
Scaffolding out the HTML
First thing is to delete the Tutorial.vue
and NuxtLogo.vue
files in the components/ directory. Next, we'll add SiteHeader.vue
and SiteFooter.vue
into that components folder.
We're not going to build out a full header and footer for this, but we do need at least one focusable element in each for demonstration purposes later.
<!-- components/SiteHeader.vue -->
<template>
<header>
<nuxt-link to="/">Header Link</nuxt-link>
</header>
</template>
<!-- components/SiteFooter.vue -->
<template>
<footer>
<nuxt-link to="/">Footer Link</nuxt-link>
</footer>
</template>
From there, we'll create a layouts
folder in the root of our project and add a default.vue
component. In that file, we're going to import our header and footer components and do a little CSS to get some layout going.
Quick CSS for some layout
We're setting our .site-wrapper
element to a display:flex
, then targeting our header and footer elements to set their flex-grow: 0
and our main element to flex-grow: 1
. This ensures that the footer is always at the bottom of the page and that our <main>
content area takes up as much of the screen as possible.
// layouts/default.vue
<template>
<div class="site-wrapper">
<SiteHeader />
<main>
<nuxt />
</main>
<SiteFooter />
</div>
</template>
<script>
export default {};
</script>
<style>
body {
overflow-x: hidden;
margin: 0 !important;
}
.site-wrapper {
min-height: 100vh;
display: flex;
flex-direction: column;
}
header,
footer {
flex-grow: 0;
}
main {
display: flex;
flex-grow: 1;
}
</style>
Now we're ready to get to the fun part!
Key Points
Before we jump straight into building the component, let's first make a quick list of the specs we need to hit for this component to be a11y compliant.
1. On open, focus is initially set on the close button.
2. On close, focus is placed back on the element that triggered the modal.
3. When open, focusable elements outside of the modal are unreachable through keyboard or mouse interactivity.
4. Pressing the 'Esc' key closes the modal.
This is a short list, at a glance, but these 4 items are paramount to improving user experience for those using assistive technologies.
Building the Modal Component
The next step is to create a BaseModal component. You can name it whatever you like. I like to build my apps based on the Vue Enterprise Boilerplate- which is where the name BaseModal
comes in.
You can read more about it in the previous link, but the quick summary is that you have a level of reusable dumb base components, in that they- for the most part- don't handle any data themselves. They simply emit events or values and provide a foundation of your app styles (BaseButton, BaseInput, etc..) that you can then extend as needed with confidence that all of your elements share a common design pattern. But, I digress.
The Modal Scaffold
There are four key parts our modal will start with: an open button, a close button, the background (the part that's usually a dark semi-transparent piece), and the content area itself.
With that in mind, let's put it together. We'll go ahead and mock some content in place as well and start styling stuff out.
// components/BaseModal.vue
<template>
<button type="button">
Open Modal
<div v-if="isOpen" class="modal-wrapper">
<div class="modal-content">
<button type="button">Close Modal</button>
<div>
<h2>Here is some modal content!</h2>
</div>
</div>
</div>
</button>
</template>
<script>
export default {};
</script>
<style scoped></style>
You'll notice here that the outermost element is a button itself. That's done so that later, when we extend the reusability with a slot, you'll be able wrap most anything in this BaseModal
component and have it trigger a modal. Images, buttons, cards- it's relatively endless.
Modal Styling
Styling the background
We want the background to take up the entirety of the screen, and in the future we'll also want to disable any background scrolling too.
Knowing that, we can set the position to be fixed on the .modal-wrapper
class and the top, right, bottom, and left values set to 0. We'll throw a semi-transparent black background color on there too.
Remember, this is in Vue so we can add this CSS in our single file component.
/*-- components/BaseModal --*/
<style scoped>
.modal-wrapper {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(1, 1, 1, 0.75);
}
</style>
Styling the content area
And to center up our .modal-content
area we'll set the display to flex on our .modal-wrapper
- as well as setting align-items and justify-content to center. We'll also drop a background color of white and add some padding of 3rem
to our .modal-content
.
/*-- components/BaseModal --*/
<style scoped>
.modal-wrapper {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(1, 1, 1, 0.75);
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background-color: white;
padding: 3rem;
}
</style>
Your modal should be looking something like this. It isn't the "prettiest" thing, but we're going for function here.
Building the Modal Functionality
Here's where we get into the meaty parts of it. This is where the amount of moving parts scales up a bit.
We need a few things to happen here. Our open button should trigger the modal. The close button should close it, but we also have those other specs we need to be sure we hit as we build this out.
Setting up Vuex
We're going to use Vuex here to keep track of when a modal is open anywhere on the site. Doing this will allow us to trigger other key events up the component tree.
So, let's start by creating a modal.js
file in our /store
directory. Now, this file could get more complex than our example, especially if you got into dealing with multiple modals on a single page and wanting to know not only if a modal was open, but also which modal.
For our simple usage here, we'll init the state for pageHasModalOpen
and default it to false, and we'll create a mutation and call it isModalOpen
. We'll use the mutation to update when a modal is triggered anywhere in the app
// store/modal.js
export const state = () => ({
pageHasModalOpen: false,
})
export const mutations = {
isModalOpen(state, isModalOpen) {
state.pageHasModalOpen = isModalOpen
}
}
Triggering Events
With our Vuex state in place, we now have a place to globally store when a modal is open. Now, we need to make our BaseModal
component aware of that state.
So, back in our BaseModal
component, let's import the mapState
from Vuex and then use a computed property to get access to our modal data
// components/BaseModal.vue
<script>
import { mapState } from "vuex";
export default {
computed: {
...mapState("modal", ["pageHasModalOpen"]),
},
};
</script>
In the instance that we have multiple modals on a single page, we'll want each to respond to if it specifically is open- and not our global state. We'll do that by creating an isOpen
property in our data and setting the initial value to false.
// components/BaseModal.vue
<script>
import { mapState } from "vuex";
export default {
data() {
return {
isOpen: false
}
},
computed: {
...mapState("modal", ["pageHasModalOpen"]),
},
};
</script>
Before we go any further here, let's jump up to our template and add some click events and v-ifs so we can start getting some pieces reacting.
We'll add an openModal
call for our open modal button, closeModal
for the close modal button, and lastly, we'll add v-if="isOpen"
to our div that has the .modal-wrapper
class. This makes it so that our background and content layer won't reveal itself unless it has been explicitly directed to by user input.
// components/BaseModal.vue
<template>
<button @click="openModal" type="button">
Open Modal
<div v-if="isOpen" class="modal-wrapper">
<div class="modal-content">
<button @click="closeModal" type="button">Close Modal</button>
<div>
<h2>Here is some modal content!</h2>
</div>
</div>
</div>
</button>
</template>
Now lets write our openModal
and closeModal
methods and get our buttons actually doing something!
Our open and close modal methods will be almost identical, save for the fact that they'll be sending the opposite boolean value.
Our openModal
method will first set our local isOpen
to true and then we'll fire a request to our vuex store to update isModalOpen
to true as well.
And we can go ahead and put our closeModal
method in here too and just replace any instance of true
to false
// components/BaseModal.vue
methods: {
async openModal() {
this.isOpen = true;
await this.$store.commit("modal/isModalOpen", true);
},
async closeModal() {
this.isOpen = false;
await this.$store.commit("modal/isModalOpen", false);
},
},
Now, let's do some clicking! Open modal works! Close modal... doesn't?!
That's because we need to utilize a portal
to actually send our modal content outside of that wrapping button, because it's currently swallowing any click event that happens.
There's a lib that allows us to do this for Nuxt, but it's actually a native thing in Vue 3! So, let's npm install portal-vue
and then add it into our modules in our nuxt.config.js
// nuxt.config.js
modules: [
'portal-vue/nuxt'
],
Now there's two things we need to do. Import and use portal in our BaseModal
component, and also set up a portal-target back in our default.vue
layout.
Let's get the Portal
component imported and registered in our BaseModal and then let's wrap the div with our v-if
on it in a <Portal>
tag (remember to close it too), move the v-if
to the Portal element and add an attribute of to="modal"
Your BaseModal component should look something like this right now.
// component/BaseModal.vue
<template>
<button @click="openModal" type="button">
Open Modal
<Portal v-if="isOpen" to="modal">
<div class="modal-wrapper">
<div class="modal-content">
<button @click="closeModal" type="button">
Close Modal
</button>
<div>
<h2>Here is some modal content!</h2>
</div>
</div>
</div>
</Portal>
</button>
</template>
<script>
import { mapState } from "vuex";
import { Portal } from "portal-vue";
export default {
components: {
Portal,
},
data() {
return {
isOpen: false,
};
},
computed: {
...mapState("modal", ["pageHasModalOpen"]),
},
methods: {
async openModal() {
this.isOpen = true;
await this.$store.commit("modal/isModalOpen", true);
},
async closeModal() {
this.isOpen = false;
await this.$store.commit("modal/isModalOpen", false);
},
},
};
</script>
<style scoped>
.modal-wrapper {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(1, 1, 1, 0.75);
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background-color: white;
padding: 3rem;
}
</style>
Let's jump back to our default.vue
and set up our portal-target and give it a name of modal.
// layouts/default.vue
<template>
<div class="site-wrapper">
<SiteHeader />
<main>
<nuxt />
</main>
<SiteFooter />
<PortalTarget name="modal"></PortalTarget>
</div>
</template>
Now try opening and closing again. It should work both ways!! Congrats! Now let's start checking off some of the accessibility specs.
Adding in Accessibility
Let's bring back our list from earlier and we'll just work our way down it until we're through!!
1. On open, focus is initially set on the close button.
2. On close, focus is placed back on the element that triggered the modal.
3. When open, focusable elements outside of the modal are unreachable through keyboard or mouse interactivity.
4. Pressing the 'Esc' key closes the modal.
On open, focus is initially set on the close button.
The good part is the clicking/triggering stuff is mostly done and we're just extending functionality.
Let's utilize refs to grab and focus the different elements. So, on our close modal button- since that's the one we need to focus on open- let's add the ref="closeButtonRef"
to it.
// components/BaseModal.vue
<template>
<button @click="openModal" type="button">
Open Modal
<Portal v-if="isOpen" to="modal">
<div class="modal-wrapper">
<div class="modal-content">
<button @click="closeModal" ref="closeButtonRef" type="button">
Close Modal
</button>
<div>
<h2>Here is some modal content!</h2>
</div>
</div>
</div>
</Portal>
</button>
</template>
Now, back down in our openModal
method let's target that ref and focus it using javascript. Directly after the $store.commit
let's add two await this.$nextTick()
- and to be completely honest, I have absolutely no idea why two are needed, but it works and I haven't seen it done any other way. After that, we'll just target our ref and call the .focus()
method on it.
// components/BaseModal.vue
async openModal() {
this.isOpen = true;
await this.$store.commit("modal/isModalOpen", true);
await this.$nextTick();
await this.$nextTick();
this.$refs.closeButtonRef?.focus()
},
Now your close button should be focused when the modal is open. You may be missing some styles to make that apparent if you're following this one to one- but you can add some CSS and target the buttons focus state to make it more apparent
/*-- components/BaseModal.vue
.modal-content button:focus {
background-color: red;
color: white;
}
On close, focus is placed back on the element that triggered the modal.
The pattern is very similar for targeting the open button when the modal is closed. We'll add a ref to the open modal button, the $nextTicks()
after the store.commit
call, and lastly targeting the ref and calling the .focus()
method.
// components/BaseModal.vue
async closeModal() {
this.isOpen = false;
await this.$store.commit("modal/isModalOpen", false);
await this.$nextTick();
await this.$nextTick();
this.$refs.openButtonRef?.focus()
},
Add an open-button
class to the button and add the selector to your :focus
CSS and you'll get to see it working!!
// components/BaseModal.vue
.open-button:focus,
.modal-content button:focus {
background-color: red;
color: white;
}
When open, focusable elements outside of the modal are unreachable through keyboard or mouse interactivity.
Thanks to some really awesome packages, we no longer have to .querySelectorAll
and jump through a bunch of javascript hoops to trap focus for modals.
We'll be using wicg-inert for our project. So let's run npm install wicg-inert
in our terminal to get it into our project.
From there, we'll create a plugin module for it called wicg-inert.client.js
- we're adding .client
because we only want this to run on the client side.
// plugins/wicg-inert.client.js
import 'wicg-inert'
And now we'll register that plugin in our nuxt.config.js
// nuxt.config.js
plugins: ["~/plugins/wicg-inert.client.js"],
Now that we have access to the inert plugin, let's jump to our default.vue
file and put it to use!
The idea of making something inert
is essentially making any content (focusable or not) unreachable- and that's exactly what we need.
If you open your modal now and tab
or shft + tab
around, you'll see we can still actually get to everything behind our dark background. And that's what this is stopping.
First, we need to import our Vuex state again, because that's what we'll use to determine when to apply the inert attribute. So, similar to what we did in our BaseModal
component, we'll import mapState from Vuex and then use a computed property to expose the value we need.
// layouts/default.vue
<script>
import { mapState } from "vuex";
export default {
computed: {
...mapState("modal", ["pageHasModalOpen"]),
},
};
</script>
From here, we'll add the inert
attribute to our <SiteHeader>
, <main>
, and <SiteFooter>
elements with the value pageHasModalOpen
. So, when it sees that a modal is open, it will apply inert and block off any content within those elements.
// layouts/default.vue
<template>
<div class="site-wrapper">
<SiteHeader :inert="pageHasModalOpen" />
<main :inert="pageHasModalOpen">
<nuxt />
</main>
<SiteFooter :inert="pageHasModalOpen" />
<PortalTarget name="modal"></PortalTarget>
</div>
</template>
Viola! Open your modal and try to tab around. If you're following this one to one, you'll see you can only tab between the URL bar and the close button element. That's because everything is being hidden with inert!
Pressing the 'Esc' key closes the modal.
We've done a lot of work so far, and all the kudos to you for making it this far. I know I can be long-winded and I appreciate your continued reading!
One of our last moves to make this accessible is to close the modal if someone presses the esc
key. Vue is super rad and gives us keybinding we can tap into to make this party incredibly easy.
Back in our BaseModal.vue
, all we have to do is add @keydown.esc="closeModal"
to our div with the .modal-wrapper
class.
Boom! Another thing off the list. That actually concludes the accessible part of this write-up!!
Congrats! We built an accessible modal!
Named Slots for Reusability
Right now, all of our content is hardcoded into the component- but we can use Vue's named slots to make this a reusable component
Let's start by replacing our Open Modal
text with <slot name="button" />
and our div just below our close button with <slot name="content" />
.
Your template in BaseModal.vue
should look something like this.
// components/BaseModal.vue
<template>
<button
class="open-button"
@click="openModal"
ref="openButtonRef"
type="button"
>
<slot name="button" />
<Portal v-if="isOpen" to="modal">
<div class="modal-wrapper" @keydown.esc="closeModal">
<div class="modal-content">
<button @click="closeModal" ref="closeButtonRef" type="button">
Close Modal
</button>
<slot name="content" />
</div>
</div>
</Portal>
</button>
</template>
From here, we can go back to our index.vue
in our pages
folder where we're using the BaseModal
component and put our content back in there, targeting the named slots to make sure everything goes to the right spot.
// pages/index.vue
<template>
<section>
<BaseModal>
<template v-slot:button>Open Modal</template>
<template v-slot:content><h2>Here is some modal content.</h2></template>
</BaseModal>
</section>
</template>
And there you have it!! A reusable and accessibility compliant modal!
Wrapping Up
Well, I hope you enjoyed this write-up. What we did isn't that difficult or complex to build. It's all about knowing what the base a11y compliant specs are and at least making sure those are met. Fun fact, your mobile menu is a modal- build it as such!!
Posted on November 15, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 12, 2024