Tutorial

Create a Bootstrap Like Modal with Tailwind and Alpine.js

This post was updated 01 May - 2020

tailwind modal bootstrap alpinejs

In certain scenarios, it doesn't really make sense to use LiveView. That can be toggling dropdowns, tabs, accordions, and opening modals. There is a great minimal library for that called Alpine.js.

So in this mini-tutorial, I will build a bootstrap 4-like animated modal using Tailwind markup and sprinkle some Alpine.js on it.

STEP 1 - Installation

Since I want to use the CSS transitions that come with Tailwind 1.2.0 I need to make sure I am on the latest version. And since that is not released yet, I can pull in the latest canary version. So I need to update my package.json

"tailwindcss": "^1.2.0-canary.5"

and install that version run

yarn

To install Alpine.js I will use yarn as well.

yarn add alpinejs

And in my app.js add

import 'alpinejs'

STEP 2 - Implementation

I ended up giving the modal 3 states. CLOSED, TRANSITION, OPEN

The reason is that I couldn't get the animations working unless I added the extra transition step.

For example, regarding the backdrop. The modal has the initial state CLOSED. When the button is clicked, I run the function open(). That updates the state to TRANSITION.

However, It has the initial opacity to 0% with opacity-0. Then I have a setTimeout(() => { this.state = 'OPEN' }, 50) that after 50ms changes states to OPEN and by backdrop changes opacity to opacity-25.

<!-- BACKDROP -->
<div
  :class="{ 'opacity-25': isOpen() }"
  class="z-40 fixed top-0 left-0 bottom-0 right-0 bg-black opacity-0 transition-opacity duration-200 linear"
></div>

And I solved the animation and fade-in on the modal with the same technique.

NOTE: If there is a better way to do this without the extra transition step, please let me know.

FULL CODE EXAMPLE

<div x-data="modal()">
  <button x-on:click="open()" type="button" class="inline-block font-normal text-center px-3 py-2 leading-normal text-base rounded cursor-pointer text-white bg-blue-600" data-toggle="modal" data-target="#exampleModalTwo">
    Launch modal
  </button>

  <!-- MODAL CONTAINER WITH BACKDROP -->
  <div x-show="isOpening()">

    <!-- MODAL -->
    <div
      :class="{ 'opacity-0': isOpening(), 'opacity-100': isOpen() }"
      class="fixed z-50 top-0 left-0 w-full h-full outline-none transition-opacity duration-200 linear"
      tabindex="-1"
      role="dialog"
    >

      <!-- MODAL DIALOG -->
      <div
        :class="{ 'mt-4': isOpening(), 'mt-8': isOpen() }"
        class="relative w-auto pointer-events-none max-w-lg mt-8 mx-auto transition-all duration-200 ease-out"
      >

        <!-- MODAL CONTAINER -->
        <div class="relative flex flex-col w-full pointer-events-auto bg-white border border-gray-300 rounded-lg shadow-xl">
          <div class="flex items-start justify-between p-4 border-b border-gray-300 rounded-t">
            <h5 class="mb-0 text-lg leading-normal">Awesome Modal</h5>
            <button
              type="button"
              class="close"
              x-on:click="close()"
            >&times;</button>
          </div>
          <div class="relative flex p-4">
            ...
          </div>
          <div class="flex items-center justify-end p-4 border-t border-gray-300">
            <button
              x-on:click="close()"
              type="button"
              class="inline-block font-normal text-center px-3 py-2 leading-normal text-base rounded cursor-pointer text-white bg-gray-600 mr-2"
            >Close</button>
            <button
              type="button"
              class="inline-block font-normal text-center px-3 py-2 leading-normal text-base rounded cursor-pointer text-white bg-blue-600"
            >Save changes</button>
          </div>
        </div>
      </div>
    </div>

    <!-- BACKDROP -->
    <div
      :class="{ 'opacity-25': isOpen() }"
      class="z-40 fixed top-0 left-0 bottom-0 right-0 bg-black opacity-0 transition-opacity duration-200 linear"
    ></div>
  </div>
</div>

<script>
  function modal() {
    return {
      state: 'CLOSED', // [CLOSED, TRANSITION, OPEN]
      open() {
        this.state = 'TRANSITION'
        setTimeout(() => { this.state = 'OPEN' }, 50)
      },
      close() {
        this.state = 'TRANSITION'
        setTimeout(() => { this.state = 'CLOSED' }, 300)
      },
      isOpen() { return this.state === 'OPEN' },
      isOpening() { return this.state !== 'CLOSED' },
    }
  }
</script>

Sources

Alpine.js

Final Result

Related Tutorials

Published 11 Jul - 2020
Updated 22 May - 2021

Create a reusable modal with LiveView Component

To reduce duplicity and complexity in your apps, Phoenix LiveView comes with the possibility to use reusable components. Each component can have its..

Published 04 May - 2021
Updated 05 May - 2022

How to combine Phoenix LiveView with Alpine.js

No matter how great Phoenix LiveView is, there is still some use case for sprinking some JS in your app to improve UX. For example, tabs, dropdowns,..