Skip to main content

By Rachel Opperman

Implementing Focus Traps in Vue for Accessibility

If you’ve spent any amount of time on the internet, you’ve likely interacted with more than a few modals, mobile navigation menus, and dropdown menus.

If you’ve done so with a mouse or trackpad, they’ve probably worked just fine for you.

But if you switch to using a keyboard, don’t be surprised if the experience isn’t quite as smooth.

If built without accessibility in mind, those UI elements can be difficult to use for keyboard-only and screen reader users. Typically, the main reason for this is the lack of a focus trap.

But what's a focus trap?

We're so glad you asked. A focus trap is functionality implemented in JavaScript that limits keyboard focus to a specific set of elements. For example, when a modal is opened, a focus trap will prevent any focusable elements (e.g., links, buttons) behind the modal from receiving keyboard focus – it will be "trapped" inside of the modal.

Why would we want this behavior, though? Excellent question.

In this post, we'll discuss how focus traps improve accessibility. Then we'll show you how to create a custom Vue directive that can be used to trigger a focus trap in a component.

Read on to find out how a small amount of code can lead to big improvements in your site's accessibility.

How focus traps improve accessibility

Let's continue with our modal example from the introduction.

Most modals have an overlay when they're opened, resulting in somewhat blocking out the rest of the screen. You may have noticed that you typically can't click on any page content behind a modal, and this is by design. The modal's content is meant to be the main focus; we want the user to interact with what's in the modal, not what's behind it.

The same thing applies for keyboard-only users. The difference is that, without a focus trap, you can hit Tab on a keyboard and still be able to focus on interactive page content behind the modal. This is precisely what we don't want.

Imagine trying to interact with a modal via the keyboard. To have the best experience, you'd need a couple of things to happen.

First, when the modal is opened, keyboard focus should move into the modal, not remain behind the modal. If it remains behind the modal, how are you supposed to interact with the modal's content?

Also, when pressing Tab, keyboard focus should loop through only the interactive elements within the modal. Imagine having to worry about going a couple of Tab presses too far and ending up with keyboard focus outside of the open modal. You'd have to do extra work to get back to where you need to be.

A fullscreen mobile navigation menu is another great example of a UI element that needs a focus trap to make it accessible. Tabbing through the menu only to have your keyboard focus disappear because it's moved to an element behind the menu is far from ideal.

Focus traps allow us to prevent these potentially frustrating experiences.

If you're working with Vue, implementing focus traps is easier than you'd probably expect it to be. Let's learn how we can make a custom focus trap directive.

Making a custom focus trap directive in Vue

First, we'll go over how to make and register a custom focus trap directive. Then we'll go over a couple examples of using the directive in components.

You can view all of the code for this post in a GitHub gist.

Step 1: Making the directive

Create a directives folder and add the focusTrap.js file. (Need tips on converting it to TypeScript? Feel free to reach out to us. We'd be happy to help.)

You'll need to install the focus-trap package, which will allow us to create, activate, and deactivate a focus trap.

npm install focus-trap

Let's take a look at the code for the directive.

import { createFocusTrap } from 'focus-trap'

let trap

const createTrap = (element) => {
  trap = createFocusTrap(element, {
    escapeDeactivates: true,
    allowOutsideClick: true,
  })
}

const focusTrap = {
  updated(element, binding) {
    if (!trap && binding.value) {
      const focusTrapElement = binding.arg
        ? [element, ...binding.arg]
        : element


      createTrap(focusTrapElement)


      setTimeout(() => {
        trap.activate()
      })
    } else if (trap && !binding.value) {
      trap.deactivate()
    }
  },
}

export default focusTrap

First, we make a createTrap function, which allows us to pass in the element to which the focus trap gets applied. The options that are passed as the second argument to createFocusTrap can be customized to your needs. You can view all of the create options in the package's documentation.

Next, we create the custom directive. We're using the updated directive hook, which is called after the parent component and all of its children have updated. This hook works well because it is triggered when a modal or menu is opened and closed, which is exactly what we need.

Inside the directive hook, we check two things:

  1. If a focus trap already exists – so we know whether we should be activating or deactivating the trap.

  2. If binding.value is true or false – this is the boolean value we'll pass to the directive when using it in the component.

Creating a new trap

If we don't have an existing trap and binding.value is true, then we want to create a new focus trap.

You may be wondering what binding.arg is. A Vue directive can optionally receive an argument. To make this directive more flexible, it would be helpful to be able to pass certain focusable elements that are outside of the component that we still need to remain keyboard focusable when the focus trap is activated.

For example, the button for closing a mobile navigation menu could be outside of the menu component itself. If we don't account for that when creating a focus trap, then a keyboard user won't be able to reach the close button.

This is where binding.arg comes in. If it's not null, then we know we have an array of DOM elements that need to be included in the focus trap, so we include them when creating it. If it is null, then we just create the trap on the component on which the directive was placed.

We then create the trap and activate it. We're activating it inside of setTimeout() to ensure it's actually been created first.

With the trap created and activated, keyboard focus will be trapped inside of the component.

Deactivating an existing trap

If we already have a trap and binding.value is false, then we want to deactivate the existing trap. We simply have to call trap.deactivate() to do so.

When the trap is deactivated, all interactive elements outside of the component will once again be able to receive keyboard focus.

Step 2: Registering the directive

To register the directive, open up the file where the Vue app is being created (typically app.js or main.js).

You should have something like this in place already.

const app = createApp({
  // Any options here
})

app.mount('#app')

We'll add our custom directive after the app is created but before it's mounted.

import focusTrap from './directives/focusTrap'

const app = createApp({
  // Any options here
})

app.directive('trap', focusTrap)

app.mount('#app')

Registering the directive like this will enable us to use v-trap in components. Feel free to give it a different name if you'd prefer, keeping in mind that whatever you pass as the first argument to app.directive() is what you'll use in components.

So, we have our focus trap directive. But how do we actually use it? Let’s take a look at a couple of examples.

Examples of using the custom focus trap directive

We've discussed modals and mobile navigation menus as two of the UI elements that benefit from having a focus trap. Let's take a look at using v-trap in Vue components for both of those UI elements.

Example #1: A reusable modal component

Imagine we have an AppModal component that looks something like this:

<template>
  <div
    v-if="name === openedModal"
    v-trap="modalIsOpen"
  >
    <div
      role="dialog"
      aria-labelledby="modalTitle"
    >
      <button
        type="button"
        aria-label="Close Modal"
        @click="handleCloseModal"
        @keydown.enter.prevent="handleCloseModal"
      >
        <!-- SVG for a close icon here -->
      </button>
      <div id="modalTitle">
        <slot name="header" />
      </div>
      <div>
        <slot name="content" />
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'

import { useModalStore } from '../store/modules/modal'

defineProps({
  name: {
    type: String,
    required: true,
  }
})

const { openedModal } = storeToRefs(useModalStore())

const modalIsOpen = computed(() => !!openedModal.value)

const handleCloseModal = () => {
  // Logic for resetting "openedModal" to an empty string in the store
}
</script>

The first thing to notice is that the v-trap directive is placed on the top-level <div> element. This ensures that we're trapping focus inside of that element.

v-trap is also receiving a value of modalIsOpen, which is a computed property that will evaluate to true if a modal is open and false otherwise. This is what will be received as binding.value in the directive code.

So, when the modal is opened, v-trap is triggered and a focus trap is created. The only keyboard focusable elements will be the interactive elements (e.g., links, buttons, form inputs) that are inside of the modal.

When the modal is closed, modalIsOpen will evaluate to false, so the focus trap will be deactivated.

Example #2: A mobile navigation menu

Now let's take a look at the code for a MobileNavigation component:

<template>
  <div
    v-trap:[focusableElements]="mobileNavIsOpen"
    role="dialog"
  >
    <nav
      aria-label="Main Navigation"
      @keydown.esc="resetMobileNav"
    >
      <ul>
        <li
          v-for="(navItem, index) in navItems"
          :key="`nav-item-${index}`"
        >
          <a
            :href="navItem.url"
            v-text="navItem.title"
          />
        </li>
      </ul>
    </nav>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'

import { useUiStore } from '../store/modules/ui'

defineProps({
  navItems: {
    type: Array,
    required: true,
  },
})

// This is an element that's outside of this component
const focusableElements = ['#header-nav']

const { mobileNavIsOpen } = storeToRefs(useUiStore())

const resetMobileNav = () => {
  // Logic for resetting "mobileNavIsOpen" to false in the store
}
</script>

In this case, v-trap is receiving both a value (mobileNavIsOpen) and an argument (focusableElements).

The value functions the same way as in the modal – triggering a focus trap when true and deactivating the trap when false.

The argument is what allows us to specify elements that are outside of the component that we still need to remain keyboard focusable when the mobile navigation menu is open. In this case, we're imagining that we have the close button for the menu in an element with the id of header-nav, and this element is outside of the <div> in this component that's being used as the "container" for the focus trap.

So, when the focus trap is activated, only the interactive items inside of this component's wrapping <div> and those specified in the focusableElements array will be able to receive keyboard focus.

Wrapping up

Implementing focus traps in certain UI elements improves your site's accessibility and enhances the user experience for keyboard-only and screen reader users. It ensures that keyboard focus is limited to the relevant areas and prevents it from getting "lost" somewhere else on the screen when something like a modal or mobile navigation menu is open.

In Vue, we can easily activate and deactivate focus traps when needed by writing a custom directive, which can then be used in any component for which a focus trap is necessary. The directive requires surprisingly little code and can easily be made flexible enough to account for allowing focus on specific elements that are outside of the component.

We hope you give this a try so you can see what a difference it can make for all users. If you have any questions or comments, please feel free to reach out to us @zaengle.

Want to read more tips and insights on working with a Vue development team that wants to help your organization grow for good? Sign up for our bimonthly newsletter.

By Rachel Opperman

Engineer

What happens when you cross years of study in biology and medicine with a degree in computer science? You get someone like Rachel.