loading...

Tailwind Enter/Leave Transition Effects with Stimulus.js

mmccall10 profile image Mike McCall ・7 min read

As a fan of TailwindUI, I was disappointed to find it impossible to support the suggested enter/leave without using Apline.js or Vue.js. The tailwind team even built their own transition component for react support. Working with Ruby on Rails and Stimulus.js I wanted to keep the project close to "core" rails. I didn't feel including Alpine.js or switching to Vue.js just for transitions was optimal. For what it's worth I think both Alpine and Vue are awesome!

Initially, I didn't bother trying. A little bit of research and a few youtube videos later I came up short. I settled on simply hiding/showing the element or applying a generic animation from something such as animate.css. But I just couldn't accept that it was impossible to implement. Plus it didn't look as nice. I dug a little deeper and came across this amazing article. I highly recommend reading it to understand how the transitions are implemented. I learned a lot and give the author a lot of credit!

I was able to take the lessons from the article and abstract it into a handy little npm package el-transition. As I write this el-transition is hot off the press. Please review and contribute!

GitHub logo mmccall10 / el-transition

Apply Enter/Leave transitions


We will use this package to implement our TailwindUI transition effects!

For this example let's build a dropdown component. You can find the tailwind markup here here.

Before we get started install the el-transition package.

$ yarn add el-transition

Time to Code

The following is a direct copy of the dropdown component from TailwindUI's library. Notice the comment specifying the suggested enter/leave transition classes.

<div class="relative inline-block text-left">
  <div>
    <span class="rounded-md shadow-sm">
      <button type="button" class="inline-flex justify-center w-full rounded-md border border-gray-300 px-4 py-2 bg-white text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800 transition ease-in-out duration-150" id="options-menu" aria-haspopup="true" aria-expanded="true">
        Options
        <svg class="-mr-1 ml-2 h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
        </svg>
      </button>
    </span>
  </div>

  <!--
    Dropdown panel, show/hide based on dropdown state.

    Entering: "transition ease-out duration-100"
      From: "transform opacity-0 scale-95"
      To: "transform opacity-100 scale-100"
    Leaving: "transition ease-in duration-75"
      From: "transform opacity-100 scale-100"
      To: "transform opacity-0 scale-95"
  -->
  <div class="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg">
    <div class="rounded-md bg-white shadow-xs">
      <div class="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
        <a href="#" class="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">Account settings</a>
        <a href="#" class="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">Support</a>
        <a href="#" class="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">License</a>
        <form method="POST" action="#">
          <button type="submit" class="block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">
            Sign out
          </button>
        </form>
      </div>
    </div>
  </div>
</div>

Now let's create a dropdown stimulus controller we can connect to our dropdown element. We will add two targets button and menu and the toggleMenu function.

// dropdown_controller.js
import {Controller} from "stimulus"

export default class extends Controller {
    static targets = ["menu", "button"]

    toggleMenu() {
        if (this.menuTarget.classList.contains('hidden')) {
            // enter transition
        } else {
            // leave transition
        }
    }
}

Now we can connect the controller to the dropdown.

<!-- add dropdown controller -->
<div data-controller="dropdown" class="relative inline-block text-left">
  <div>
    <span class="rounded-md shadow-sm">

      <!-- Add button target and click action -->
      <button type="button" data-target="dropdown.button" data-action="click->dropdown#toggleMenu" class="inline-flex justify-center w-full rounded-md border border-gray-300 px-4 py-2 bg-white text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800 transition ease-in-out duration-150" id="options-menu" aria-haspopup="true" aria-expanded="true">
        Options
        <svg class="-mr-1 ml-2 h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
        </svg>
      </button>
    </span>
  </div>

  <!-- add menu target -->
  <div data-target="dropdown.menu" class="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg">
    <div class="rounded-md bg-white shadow-xs">
      <div class="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
        <a href="#" class="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">Account settings</a>
        <a href="#" class="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">Support</a>
        <a href="#" class="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">License</a>
        <form method="POST" action="#">
          <button type="submit" class="block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">
            Sign out
          </button>
        </form>
      </div>
    </div>
  </div>
</div>

Now that the controller is connected let's implement the suggested transitions.

 <!--
    Dropdown panel, show/hide based on dropdown state.

    Entering: "transition ease-out duration-100"
      From: "transform opacity-0 scale-95"
      To: "transform opacity-100 scale-100"
    Leaving: "transition ease-in duration-75"
      From: "transform opacity-100 scale-100"
      To: "transform opacity-0 scale-95"
  -->

To do this, el-transiton expects data-transition-* attributes or an animation class. We will use data attributes which is more in line with how Apline.js works. Here is our updated html with the transitions declared.

<div data-controller="dropdown" class="relative inline-block text-left">
  <div>
    <span class="rounded-md shadow-sm">
      <button type="button" data-target="dropdown.button" data-action="click->dropdown#toggleMenu" class="inline-flex justify-center w-full rounded-md border border-gray-300 px-4 py-2 bg-white text-sm leading-5 font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:border-blue-300 focus:shadow-outline-blue active:bg-gray-50 active:text-gray-800 transition ease-in-out duration-150" id="options-menu" aria-haspopup="true" aria-expanded="true">
        Options
        <svg class="-mr-1 ml-2 h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
          <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
        </svg>
      </button>
    </span>
  </div>

<!-- add transition data attributes. -->
<div class="origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg hidden"
       data-target="dropdown.menu"
       data-transition-enter="transition ease-out duration-100"
       data-transition-enter-start="transform opacity-0 scale-95"
       data-transition-enter-end="transform opacity-100 scale-100"
       data-transition-leave="transition ease-in duration-75"
       data-transition-leave-start="transform opacity-100 scale-100"
       data-transition-leave-end="transform opacity-0 scale-95"
  >
    <div class="rounded-md bg-white shadow-xs">
      <div class="py-1" role="menu" aria-orientation="vertical" aria-labelledby="options-menu">
        <a href="#" class="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">Account settings</a>
        <a href="#" class="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">Support</a>
        <a href="#" class="block px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">License</a>
        <form method="POST" action="#">
          <button type="submit" class="block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900" role="menuitem">
            Sign out
          </button>
        </form>
      </div>
    </div>
  </div>
</div>

Finally, we can update our stimulus dropdown controller to execute the enter/leave transition effects.

import {Controller} from "stimulus"
// import the enter leave functions
import {enter, leave} from 'el-transition'; 

export default class extends Controller {
    static targets = ["menu", "button"]

    // call the enter and leave functions
    toggleMenu() {
        if(this.menuTarget.classList.contains('hidden')) {
            enter(this.menuTarget)
        } else {
            leave(this.menuTarget)
        }
    }
}

That's it!

You can find a demo here:

Conclusion

I am stoked to be able to support TailwindUI's suggested transition effects as the team puts a lot of care into it and they look nice! While this example is specific to stimulus.js the el-transition is framework agnostic.

Posted on by:

Discussion

markdown guide
 

This is really great, thanks for the share! I echo exactly what Andrew Mason said too.

One thing was missing for me and that was clicking away on the window to close the dropdown. In AlpineJS it is handled with @click.away=. I wish I knew more about javascript and events, but I did find adding an event listener when the button is clicked and removing it upon close to work well. Supports clicking in the dropdown, the original button and outside (somewhere on the window).

Here's my updated dropdown_controller.js for you or anyone else listening:

import {Controller} from "stimulus"
import {enter, leave} from 'el-transition';

export default class extends Controller {
  static targets = ["menu", "button"]

  toggleMenu(event) {
    event.stopPropagation();
    if (this.menuTarget.classList.contains('hidden')) {
      document.body.addEventListener('click', event => this.closeMenu(event));
      enter(this.menuTarget)
    } else {
      this.closeMenu(event);
    }
  }

  closeMenu(event){
    document.body.removeEventListener('click', event => this.closeMenu(event));
    leave(this.menuTarget)
  }
}
 

For sure! That's a really cool solution! I agree that handling clicks outside is important. I didn't go down that road because it strayed from the purpose of the article and some UI's don't implement it. ie. npmjs.com.

FWIW I am/was doing something like this, a pattern I see in React.

import {Controller} from "stimulus"
import {leave, toggle} from 'el-transition';

export default class extends Controller {
    static targets = ["menu", "button"]

    handleClickOutside(event) {
        const menuClicked = this.menuTarget.contains(event.target)
        const buttonClicked = this.buttonTarget.contains(event.target)
        const hidden = this.menuTarget.classList.contains('hidden')

        if (!menuClicked && !buttonClicked && !hidden ) {
            leave(this.menuTarget)
        }
    }

    connect() {
        document.addEventListener('click', this.handleClickOutside.bind(this));
    }

    disconnect() {
        document.removeEventListener('click', this.handleClickOutside.bind(this));
    }

    toggleMenu() {
        toggle(this.menuTarget)
    }
}

 

Heck yah, that works great! Thanks for following up.

 

This is awesome! Thanks so much for sharing!

I had been meaning to create a transition pattern for stimulus to use with TailwindUI and just hadn’t gotten to it so I’m glad someone else needed it as well and was kind enough to share with the community🚀