Building an Accessible Modal in Vue.
Tags: webdev, javascript, vue
Published: 2021-11-15 13:32:33

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.

```html
<!-- components/SiteHeader.vue -->

<template>
  <header>
    <nuxt-link to="/">Header Link</nuxt-link>
  </header>
</template>
```

```html
<!-- 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.

```javascript
// 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](https://github.com/bencodezen/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.

```javascript
// 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.

```css
/*-- 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`.

```css
/*-- 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.
![A screenshot of an open modal with minimal styling](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b6pyvknygr322rs31olp.png)

### 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

```javascript
// 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

```javascript
// 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.

```javascript
// 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.

```javascript
// 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`

``` javascript
// 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`

```javascript
// 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.

```javascript
// 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.

```javascript
// 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.

```javascript
// 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.

```javascript
// 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

```css
/*-- 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.

```javascript
// 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!!

```javascript
// 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](https://github.com/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.

```javascript
// plugins/wicg-inert.client.js

import 'wicg-inert'
```

And now we'll register that plugin in our `nuxt.config.js`

```javascript
// 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.

```javascript
// 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.

```javascript
// 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.

```javascript
// 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.

```javascript
// 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!!