DEV Community

Cover image for Building an Accessible Modal in Vue.
Drew Clements
Drew Clements

Posted on

Building an Accessible Modal in Vue.

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>
Enter fullscreen mode Exit fullscreen mode
<!-- components/SiteFooter.vue -->

<template>
  <footer>
    <nuxt-link to="/">Footer Link</nuxt-link>
  </footer>
</template>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
  },
},
Enter fullscreen mode Exit fullscreen mode

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'
],
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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()
},
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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()
},
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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

// nuxt.config.js

plugins: ["~/plugins/wicg-inert.client.js"],
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)