diff --git a/client/src/utils/transition.test.js b/client/src/utils/transition.test.js
new file mode 100644
index 0000000000..9cffb98040
--- /dev/null
+++ b/client/src/utils/transition.test.js
@@ -0,0 +1,83 @@
+import { transition } from './transition';
+
+jest.useFakeTimers();
+
+describe('transition', () => {
+ beforeAll(() => {
+ document.body.innerHTML = ``;
+ });
+
+ it('should resolve the immediately if maxDelay is 0 or falsy', async () => {
+ expect(
+ await transition(document.getElementById('main'), { maxDelay: 0 }),
+ ).toBe(null);
+
+ expect(
+ await transition(document.getElementById('main'), { maxDelay: false }),
+ ).toBe(null);
+
+ expect(
+ await transition(document.getElementById('main'), { maxDelay: -1 }),
+ ).toBe(null);
+ });
+
+ it('should resolve after the maxDelay (350ms) if no events are fired', async () => {
+ const resolve = jest.fn();
+
+ transition(document.getElementById('main')).then(resolve);
+
+ await jest.advanceTimersByTimeAsync(200);
+
+ expect(resolve).not.toHaveBeenCalled();
+
+ await jest.advanceTimersByTimeAsync(149);
+
+ expect(resolve).not.toHaveBeenCalled();
+
+ await jest.advanceTimersByTimeAsync(1);
+
+ expect(resolve).toHaveBeenCalledWith(null);
+ });
+
+ it('should resolve if transitionend or animationend is fired', async () => {
+ const resolve = jest.fn();
+
+ transition(document.getElementById('main')).then(resolve);
+
+ await jest.advanceTimersByTimeAsync(200);
+
+ expect(resolve).not.toHaveBeenCalled();
+
+ const event = new Event('transitionend', {
+ bubbles: true,
+ cancelable: false,
+ });
+
+ document.getElementById('main').dispatchEvent(event);
+
+ await jest.advanceTimersByTimeAsync(0);
+
+ expect(resolve).toHaveBeenCalledWith(event);
+ });
+
+ it('should resolve if animationend is fired', async () => {
+ const resolve = jest.fn();
+
+ transition(document.getElementById('main')).then(resolve);
+
+ await jest.advanceTimersByTimeAsync(200);
+
+ expect(resolve).not.toHaveBeenCalled();
+
+ const event = new Event('animationend', {
+ bubbles: true,
+ cancelable: false,
+ });
+
+ document.getElementById('main').dispatchEvent(event);
+
+ await jest.advanceTimersByTimeAsync(0);
+
+ expect(resolve).toHaveBeenCalledWith(event);
+ });
+});
diff --git a/client/src/utils/transition.ts b/client/src/utils/transition.ts
new file mode 100644
index 0000000000..ac4fc25b1d
--- /dev/null
+++ b/client/src/utils/transition.ts
@@ -0,0 +1,27 @@
+/**
+ * Returns a promise that will resolve after either the animation, transition
+ * or the max delay of time is reached.
+ *
+ * If `maxDelay` is provided as zero or a falsy value, the promise resolve immediately.
+ */
+export const transition = (element: HTMLElement, { maxDelay = 350 } = {}) =>
+ new Promise((resolve) => {
+ if (!maxDelay || maxDelay <= 0) {
+ resolve(null);
+ return;
+ }
+
+ let timer: number | undefined;
+
+ const finish = (event: AnimationEvent | TransitionEvent | null) => {
+ if (event && event.target !== element) return;
+ window.clearTimeout(timer);
+ element.removeEventListener('transitionend', finish);
+ element.removeEventListener('animationend', finish);
+ resolve(event || null);
+ };
+
+ element.addEventListener('animationend', finish);
+ element.addEventListener('transitionend', finish);
+ timer = window.setTimeout(finish, maxDelay);
+ });