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!
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.
Top comments (10)
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: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.
First, thanks for a good post. And, i believe, you can doing something like
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🚀
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" ✨
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...
Thanks for this great article - was exactly what I was looking for 🚀
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.Thank you for this. It worked great for me!