DEV Community

David Berri
David Berri

Posted on • Originally published at dberri.com on

Let’s build an accessible modal with Alpine.js

The simplest example of a modal is the one you can find in Alpine.js’ own documentation, and it is for a dropdown modal which would be like this:

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

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

Enter fullscreen mode Exit fullscreen mode

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 of TailwindCSS, so don’t really use it for production):

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

Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

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:

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

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:

<button data-modal-open="modal_1"
        @click="open = true"
        class="...">
  Open Modal
</button>
Enter fullscreen mode Exit fullscreen mode

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:

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

This is the modal container. You'll notice the role attribute and it's set to "dialog" which according to W3 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:

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

Then, we finally reach the modal contents:

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

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:

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

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. 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 and I'll see you in the next one =)

Top comments (0)