DEV Community

Mike McCall
Mike McCall

Posted on

Tailwind Enter/Leave Transition Effects with Stimulus.js

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

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

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

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

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

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

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

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.

Top comments (10)

Collapse
 
andypeters profile image
Andy Peters

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)
  }
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
mmccall10 profile image
Mike McCall • Edited

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

Collapse
 
damel profile image
Dmitrii Amelchenko • Edited

First, thanks for a good post. And, i believe, you can doing something like

data-action="click->dropdown#toggleMenu click@window->dropdown#hideMenu"
Enter fullscreen mode Exit fullscreen mode
hideMenu(event) {
    const buttonClicked = this.buttonTarget.contains(event.target)

    if (!buttonClicked) {
      leave(this.menuTarget)
    }
  }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
andypeters profile image
Andy Peters

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

Collapse
 
andrewmcodes profile image
Andrew Mason

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🚀

Collapse
 
dangreaves profile image
Dan Greaves

Great work with this! It works great and your explainer is super easy to follow. This is the first result when searching for "stimulus transition js" ✨

Collapse
 
vincenttaglia profile image
Victor Vincent Taglia

Thanks for the work on el-transition! Works amazingly!

I just made myself a development environment for Stimulus and Tailwind and I used your el-transition package as part of the example. The main purpose for this environment was element prototyping speed, and I think it turned out pretty well.

Check it out if you want:

dev.to/vincenttaglia/element-proto...

github.com/vincenttaglia/RST-Eleme...

Collapse
 
oddessay profile image
Paul Bennett-Freeman

Thanks for this great article - was exactly what I was looking for 🚀

Collapse
 
tsmithdev profile image
Travis Smith

This is working great for me, along with the hideMenu function from @damel . Thanks for sharing it. It would be nice for something along these lines to be rolled into Stimulus officially.

Collapse
 
preyes323 profile image
Victor Paolo C. Reyes

Thank you for this. It worked great for me!