Let’s build an accessible modal with Alpine.js
Tags: javascript, alpinejs, tailwindcss
Published: 2021-04-05 15:37:30

---
title: Let’s build an accessible modal with Alpine.js
published: true
date: 2021-03-28 13:07:28 UTC
tags: javascript, alpinejs, tailwindcss
canonical_url: https://dberri.com/lets-build-an-accessible-modal-with-alpine-js
---

The simplest example of a modal is the one you can find in Alpine.js’ [own documentation](https://github.com/alpinejs/alpine/#use), and it is for a dropdown modal which would be like this:

```html
<div x-data="{ open: false }">
    <button @click="open = true">Open Dropdown</button>

    <ul
        x-show="open"
        @click.away="open = false"
    >
        Dropdown Body
    </ul>
</div>

```

Very straight forward, you will just control the "open" state of the modal and change with the button click event. There’s also something very cool which is the "away" event modifier. That ensures that when the modal is open, if a click happens outside of the modal tree, it will hide the it. We will use these basic concepts and build a "regular" modal. As in other Alpine.js' posts, I’ll use TailwindCSS for the styling, so all you need to do is add these two lines in the `<head>` section of your page (just remember that it is [not a purged version](https://tailwindcss.com/docs/installation#using-tailwind-via-cdn) of TailwindCSS, so don’t really use it for production):

```html
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/gh/alpinejs/alpine@v2.x.x/dist/alpine.min.js" defer></script>

```

Ok, now, we’re ready to start building stuff. Let’s begin by adding a container that will hold Alpine.js' state:

```html
<div x-data="{ open: false }">
</div>

```

Everything that Alpine.js controls and is related to the modal will need to be inside this `<div>`. You can have multiple containers like this to control different aspects of the page, but they would be independent. So, inside this container, we will add a button to open the modal and the modal’s markup as well:

```html
<div x-data="{ open: false }">
    <button x-ref="modal1_button"
            @click="open = true"
            class="w-full bg-indigo-600 px-4 py-2 border border-transparent rounded-md flex items-center justify-center text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:w-auto sm:inline-flex">
            Open Modal
    </button>

    <div role="dialog"
         aria-labelledby="modal1_label"
         aria-modal="true"
         tabindex="0"
         x-show="open"
         @click="open = false; $refs.modal1_button.focus()"
         @click.away="open = false; $refs.modal1_button.focus()"
         class="fixed top-0 left-0 w-full h-screen flex justify-center items-center">
        <div class="absolute top-0 left-0 w-full h-screen bg-black opacity-60"
             aria-hidden="true"
             x-show="open"></div>
        <div @click.stop=""
             x-show="open"
             class="flex flex-col rounded-lg shadow-lg overflow-hidden bg-white w-3/5 h-3/5 z-10">
          <div class="p-6 border-b">
            <h2 id="modal1_label">Header</h2>
          </div>
          <div class="p-6">
            Content
          </div>
        </div>
    </div>
</div>
```

This will get you a very simple modal window when you click on the "Open modal" button. No transitions, no flair, just a plain modal. Let's break down the markup:

```html
<button data-modal-open="modal_1"
        @click="open = true"
        class="...">
  Open Modal
</button>
```

This is the button that will trigger the modal to open (classes ommited), other than the known `@click="open = true"` which you are probably familiar with, we have the `x-ref="modal1_button"` attribute. This is there so we can retrieve a reference to the button element and set focus to it once the modal is closed which is helpful for people who use the keyboard to navigate around the page. Onto the next part:

```html
<div role="dialog"
     aria-labelledby="modal1_label"
     aria-modal="true"
     tabindex="0"
     x-show="open"
     @click="open = false; $refs.modal1_button.focus()"
     @click.away="open = false; $refs.modal1_button.focus()"
     class="...">
     ...
</div>
```

This is the modal container. You'll notice the `role` attribute and it's set to "dialog" which [according to W3](https://www.w3.org/TR/wai-aria-practices-1.1/examples/dialog-modal/dialog.html) is a way to identify the element that serves as the dialog container. Then, we have the `aria-labelledby` attribute, which will set the accessible name of the container to the modal title (h2 tag). Next is `aria-modal` attribute which tells accessibility technologies that the content underneath this dialog won't be available for interaction while it's open. `x-show` is probably self-explanatory and then we have `$refs.modal1_button.focus()` which will use the `x-ref` we set in the button to set focus to it once the modal is closed.

Next we have this empty div which is used as a modal backdrop, nothing special about it so we add the `aria-hidden` attribute which just hides this div from accessbility technologies:

```html
<div class="absolute top-0 left-0 w-full h-screen bg-black opacity-60"
     aria-hidden="true"
     x-show="open"></div>
```

Then, we finally reach the modal contents:

```html
        <div @click.stop=""
             x-show="open"
             class="...">
          <div class="p-6 border-b">
            <h2 id="modal1_label">Header</h2>
          </div>
          <div class="p-6">
            Content
          </div>
        </div>
```

The only important parts here are the id we set in the h2 tag, which must be equal to the one we set in `aria-labelledby` earlier and the `stop` event modifier set to the `@click` event. This will prevent the click event from bubbling up to the modal container, which would listen to it and close the modal.

That covers the markup, now let's tackle the animations:

```html
<div role="dialog"
     aria-labelledby="modal1_label"
     aria-modal="true"
     tabindex="0"
     x-show="open"
     @click="open = false; $refs.modal1_button.focus()"
     @click.away="open = false"
     class="fixed top-0 left-0 w-full h-screen flex justify-center items-center">
        <div aria-hidden="true"
             class="absolute top-0 left-0 w-full h-screen bg-black transition duration-300"
             :class="{ 'opacity-60': open, 'opacity-0': !open }"
             x-show="open"
             x-transition:leave="delay-150"></div>
        <div data-modal-document
             @click.stop=""
             x-show="open"
             x-transition:enter="transition ease-out duration-300"
             x-transition:enter-start="transform scale-50 opacity-0"
             x-transition:enter-end="transform scale-100 opacity-100"
             x-transition:leave="transition ease-out duration-300"
             x-transition:leave-start="transform scale-100 opacity-100"
             x-transition:leave-end="transform scale-50 opacity-0"
             class="flex flex-col rounded-lg shadow-lg overflow-hidden bg-white w-3/5 h-3/5 z-10">
          <div class="p-6 border-b">
              <h2 id="modal1_label" x-ref="modal1_label">Header</h2>
          </div>
          <div class="p-6">
              Content
          </div>
        </div>
</div>
```

Here we set an opacity animation to the modal backdrop: it starts with the `opacity-0` class and once the `open` property changes to true, it will replace `opacity-0` with `opacity-60`. This transition is handled by the TailwindCSS classes `transition duration-300` which should be self-explanatory, but if you want more details, [check it out here](https://tailwindcss.com/docs/transition-property). An interesting in this element is that we use Alpine's `x-transition:leave` to add a delay when closing the modal. This will make sure that the backdrop will start to fade out after the rest of the modal is already half way through its transition.

In the modal dialog itself, we use a more granular approach to transition it using various `x-transition` properties:

`x-transition:enter` will set the classes that will be will be attached to element the entire "enter" transition. So we use it to add the transition property, duration and easing.

`x-transition:enter-start` set the classes that define the initial state of the elements and `x-transition:enter-end` are the classes that defined the end state of the "enter" transition. Here we're saying that the modal should start with a 0% opacity and scaled down to 50% its size and should end with a 100% opacity and scaled up to its original size.

`x-transition:leave-start` and `x-transition:leave-end` will do the opposite of the `enter` transitions, so we also do the opposite with the dialog box: start from original size and 100% opacity to 50% its size and 0% opacity.

And that wraps it! If you're new to Alpine.js, [check out this post](https://dberri.com/lets-build-a-tab-navigation-with-alpine-js) and I'll see you in the next one =)