DEV Community

Cover image for Using a Vanilla JavaScript module to handle CSS transition states
Sebastian Nitu
Sebastian Nitu

Posted on

Using a Vanilla JavaScript module to handle CSS transition states

In a lot of my front-end work, I end up having to create open and close transitions for components. Things like modals, drawers, dropdowns, etc. After doing that a few times, I started to notice a pattern and wondered if I could turn that pattern into a reusable module instead of re-writing variations of that same logic. These were my core requirements:

  1. The transitions should be handled by CSS, not JavaScript. That means if the transition duration for one component is different from another, the JS should just work in both cases.
  2. There should be the ability to turn off transitions and just switch between the two final states.
  3. Transitions should prevent spamming, meaning I don't want to trigger the "closing" transition if the component is currently "opening".
  4. Return a promise so we have a way to write code that happens after a transition has finished.

Here's a working example of the code we'll be writing:

Notice: The example in this article is for an "open" and "close" state, but it really could be the transition between any two states. "Open" and "close" are just the two I use the most, even tho I'm aware the example transitions aren't actually "open" or "close" states.

Lets start with options

First, I want to create an options object where we can store our settings. We'll want to define our state classes here and whether or not transitioning is enabled:

const options = {
  stateOpened: "is-opened",
  stateOpening: "is-opening",
  stateClosed: "is-closed",
  stateClosing: "is-closing",
  transition: true
};

Create our transition module

Next, lets create a new file called transition.js and define an "open" function that returns a promise.

const openTransition = (el, settings) => {
  return new Promise((resolve) => {
    resolve(el);
  });
};

Right now, it's not doing much, we're just resolving the promise and returning our passed element. So lets put together our transition condition first.

const openTransition = (el, settings) => {
  return new Promise((resolve) => {
    if (settings.transition) {
      // Lets transition the element...
    } else {
      // Transition is disabled, just swap the state
    }
  });
};

For our disabled logic, that's pretty simple, we just need to remove our closed state and add our opened instead. Then we can resolve the promise.

el.classList.add(settings.stateClosed);
el.classList.remove(settings.stateOpened);
resolve(el);

Now, if transitions are enabled, we want to do the following:

  1. Begin the transition from state a -> b by setting the transitioning class and listening for the transitionend event.
  2. Once transitionend has been hit, we can then swap our transition class for our final state and resolve the promise.
el.classList.remove(settings.stateClosed);
el.classList.add(settings.stateOpening);
el.addEventListener('transitionend', function _f() {
  el.classList.add(settings.stateOpened);
  el.classList.remove(settings.stateOpening);
  resolve(el);
  this.removeEventListener('transitionend', _f);
});

The cool thing about this is we're only adding an event listener during transition. Once that transition has finished, we can remove it until another transition call is made.

Our final code for the openTransition now looks like this:

const openTransition = (el, settings) => {
  return new Promise((resolve) => {
    if (settings.transition) {
      el.classList.remove(settings.stateClosed);
      el.classList.add(settings.stateOpening);
      el.addEventListener('transitionend', function _f() {
        el.classList.add(settings.stateOpened);
        el.classList.remove(settings.stateOpening);
        resolve(el);
        this.removeEventListener('transitionend', _f);
      });
    } else {
      el.classList.add(settings.stateOpened);
      el.classList.remove(settings.stateClosed);
      resolve(el);
    }
  });
};

With that finished, we can put together the corollary closeTransition function pretty easily by simply moving around which classes are being added and removed, like so:

const closeTransition = (el, settings) => {
  return new Promise((resolve) => {
    if (settings.transition) {
      el.classList.add(settings.stateClosing);
      el.classList.remove(settings.stateOpened);
      el.addEventListener('transitionend', function _f() {
        el.classList.remove(settings.stateClosing);
        el.classList.add(settings.stateClosed);
        resolve(el);
        this.removeEventListener('transitionend', _f);
      });
    } else {
      el.classList.add(settings.stateClosed);
      el.classList.remove(settings.stateOpened);
      resolve(el);
    }
  });
};

To turn these two functions into modules, we'll just need to export them both, like this:

// transition.js
export const openTransition = (el, settings) => {
  // ...
};

export const closeTransition = (el, settings) => {
  // ...
};

Adding our markup and trigger logic

Lets start with a contrived example just to illustrate how flexible these transition functions are. Lets create an index.html file where we have a button and then some element we'll transition between two states.

<!-- index.html -->
<button class="button">Trigger</button>
<div class="box-track">
  <div class="box is-closed"></div>
</div>

It's important to note that we're adding our components default state directly, in this case is-closed. If you wanted the default state to be open, just add is-opened instead.

Now, lets create an index.js file where where we'll import our new transition module, define our options and get ready to work with our two elements.

// index.js
import {
  openTransition,
  closeTransition
} from "./transition";

const options = {
  stateOpened: "is-opened",
  stateOpening: "is-opening",
  stateClosed: "is-closed",
  stateClosing: "is-closing",
  transition: true
};

const el = document.querySelector(".box");
const btn = document.querySelector(".button");

Next, lets add a click even listener to our button. Notice that this is where we'll be checking if our component has finished transitioning. We don't do anything if our component isn't in a "final" state such as is-opened or is-closed.

btn.addEventListener("click", () => {
  if (el.classList.contains(options.stateClosed)) {
    // ...
  } else if (el.classList.contains(options.stateOpened)) {
    // ...
  }
});

Now all we have to do is use our imported transition module and open the component when it's closed, or close it when it's opened. We'll write this asynchronously to take advantage that we're returning a promise.

btn.addEventListener("click", async () => {
  if (el.classList.contains(options.stateClosed)) {
    await openTransition(el, options);
    // Do stuff after open transition has finished...
  } else if (el.classList.contains(options.stateOpened)) {
    await closeTransition(el, options);
    // Do stuff after close transition has finished...
  }
});

And that's it for our JavaScript! The final index.js should now look like this:

// index.js
import {
  openTransition,
  closeTransition
} from "@vrembem/core/src/js/transition";

const options = {
  stateOpened: "is-opened",
  stateOpening: "is-opening",
  stateClosed: "is-closed",
  stateClosing: "is-closing",
  transition: true
};

const el = document.querySelector(".box");
const btn = document.querySelector(".button");

btn.addEventListener("click", async () => {
  if (el.classList.contains(options.stateClosed)) {
    await openTransition(el, options);
  } else if (el.classList.contains(options.stateOpened)) {
    await closeTransition(el, options);
  }
});

Adding our CSS transitions

The last part of our example is adding the CSS transitions to our component. Here's the beauty in all of this, we can essentially write any transition with any transition duration and our JavaScript should handle it just fine.

For simplicity, we'll just be transitioning between a background color and transform properties so it's not a true "opened" and "closed" state but it shows what's possible using minimal styles. Here's our base styles:

.box-track {
  position: relative;
}

.box {
  position: absolute;
  width: 50%;
  height: 6em;
  border-radius: 8px;
}

Now, lets introduce our state styles. This will be the styles that our final states will have:

.box.is-opened,
.box.is-opening {
  background: salmon;
  transform: translateX(100%);
}

.box.is-closed,
.box.is-closing {
  background: forestgreen;
  transform: translateX(0);
}

Finally, lets add our transition styles only to our states that care about transitioning, is-opening and is-closing:

.box.is-opening,
.box.is-closing {
  transition-property: background, transform;
  transition-duration: 1s;
  transition-timing-function: ease-in-out;
}

Conclusion

Putting all these together, we now have a reusable transition module that can be used across multiple components. Our transitions themselves are handled completely by our CSS and we can add to our transitions module with different transition types as needed.

Here's a few resources along with two components that use the above transition module:

Have any questions or suggestions for improving the code above? Drop a comment below and thanks for reading!

Top comments (0)