Minimal React Transition Hook (useTransition)

I often find myself in the need of a very basic open/close transition in React for components like dialogs, side sheets, or dropdowns. The goto solution for a while seemed to be React Transition Group, which I never understood how to use properly. An excellent solution for animations is react-spring, but I'd consider it an overkill for a basic CSS powered open/close transition (but it's great for animations in something like an image viewer).

This is why I’ve ended up writing my own hook: react-css-transition-hook

It is used like this:

const { isOpen } = useMenu();
const [isVisible, props] = useTransition(isOpen, {
  entering: "transition ease-out duration-100 transform opacity-0 scale-95",
  entered: "transition ease-out duration-100 transform opacity-100 scale-100",
  exiting: "transition ease-in duration-75 transform opacity-100 scale-100",
  exited: "transition ease-in duration-75 transform opacity-0 scale-95",
});

if (!isVisible) {
  return null
}

return (
  <div {...props}>
    ...
  </div>
)

Isn’t that easy to understand and reason about just from reading the usage? Here is a complete example using the hook: Demo, Source.

The hook itself is just ~50 lines long (excluding the typings and doc comments) and so simple, it easily fits into this post:

export function useTransition(
  desiredState: boolean,
  opts: UseTransitionOpts
): [boolean, TransitionProps, TransitionStep] {
  const [currentState, setCurrentState] = useState(
    Boolean(desiredState && opts.disableInitialEnterTransition)
  );
  const [transition, setTransition] = useState<TransitionStep>(() =>
    desiredState ? "entered" : null
  );

  useEffect(() => {
    // exited -> entering
    if (!currentState && desiredState) {
      setCurrentState(true);
      setTransition("entering");
    }
    // entered -> exited
    else if (currentState && !desiredState) {
      setTransition("exiting");
    }
  }, [currentState, desiredState]);

  // Once the state changed to true, trigger another re-render for the switch to
  // the entered classnames
  useEffect(() => {
    switch (transition) {
      case "entering":
        setTransition("entered");
        break;
      case "exiting":
        setTransition("exited");
        break;
    }
  }, [transition]);

  const onTransitionEnd = useCallback(() => {
    if (!desiredState) {
      setCurrentState(false);
      setTransition(null);
    }
  }, [desiredState]);

  return [
    currentState,
    { className: transition ? opts[transition] ?? "" : "", onTransitionEnd },
    transition,
  ];
}

This is exactly what I wanted. Simple, small, no fancy magic - just using basic useState, useEffect, and useCallback hooks.

Let’s dissect its inner workings top-down.

Typically, when a component is closed, it is just not rendered anymore. This does not work well with a close transition, because it is necessary to keep the component in the DOM until the close transition finished. This is why the hook takes the desired state (visible or not; isOpen in the usage example above, and desiredState in the code above) as an input, and returns whether you should still render the component or not (isVisible in the example usage above, and currentState in the code below).

const [currentState, setCurrentState] = useState(
  Boolean(desiredState && opts.disableInitialEnterTransition)
);
const [transition, setTransition] = useState<TransitionStep>(() =>
  desiredState ? "entered" : null
);

When the hook is first used, it determines what the initial state is and also provides an option to skip the enter transition if it starts being visible right away. It also sets its initial transition state (transition), which is either entered, if the component is already visible, or null if it is not.

useEffect(() => {
  // exited -> entering
  if (!currentState && desiredState) {
    setCurrentState(true);
    setTransition("entering");
  }
  // entered -> exited
  else if (currentState && !desiredState) {
    setTransition("exiting");
  }
}, [currentState, desiredState]);

When either the current or desired states change, it updates the active transition accordingly:

For the open transition, the transition cannot be set to entered right away. It is always necessary to render the component with entering first so that there is a starting point for the transition to be based on. Example:

  1. Render with 0% opacity, and once that is reflected in the DOM,
  2. Set the opacity to 100% for the transition to start.

This is what the second useEffect is for.

useEffect(() => {
  switch (transition) {
    case "entering":
      setTransition("entered");
      break;
    case "exiting":
      setTransition("exited");
      break;
  }
}, [transition]);

The second useEffect cannot be integrated into the first one, because there needs to be a DOM update before the state changes of the second useEffect are applied. By separating them, the state changes from the first effect are reflected in the DOM before the whole hook is called again and applies the changes from the second effect. The second effect is thereby simply reacting on the changes from the first useEffect and kicks of the transition by moving from entering to entered, or from exiting to exited.

const onTransitionEnd = useCallback(() => {
  if (!desiredState) {
    setCurrentState(false);
    setTransition(null);
  }
}, [desiredState]);

It is necessary to know when the close transition finished so that the component can be removed from the DOM. This is achieved by a simple onTransitionEnd event handler. Once fired, it sets the current state to false and resets the transition to null.

That’s all there is to it.

Finally, as a small bonus, an advanced example of how to use it for a Radix UI Dialog-based side sheet:

import React, { PropsWithChildren, useCallback } from "react";
import * as Dialog from "@radix-ui/react-dialog";
import { XIcon } from "@heroicons/react/outline";
import { useTransition } from "react-css-transition-hook";
import classNames from "classnames";

export default function SideSheet({
  isOpen,
  dismiss,
  title,
  children,
}: PropsWithChildren<{ isOpen: true; dismiss(): void; title: string }>) {
  const handleOpenChange = useCallback(
    (open: boolean) => {
      if (!open) {
        dismiss();
      }
    },
    [dismiss]
  );

  const [isVisible, { className: contentClassName, ...props }, step] =
    useTransition(isOpen, {
      entering: "translate-x-full",
      entered: "translate-x-0",
      exiting: "translate-x-0",
      exited: "translate-x-full",
    });

  const backdropClassName = step
    ? {
        entering: "opacity-0",
        entered: "opacity-100",
        exiting: "opacity-100",
        exited: "opacity-0",
      }[step]
    : "";

  if (!isVisible) {
    return null;
  }

  return (
    <Dialog.Root open={true} onOpenChange={handleOpenChange}>
      <Dialog.Overlay
        className={classNames(
          "fixed inset-0 bg-black bg-opacity-50",
          "transition-opacity duration-500 ease-in-out",
          backdropClassName
        )}
      />
      <Dialog.Content
        className={classNames(
          "fixed inset-y-0 right-0 px-4 md:px-16 pt-8 pb-16",
          "w-screen max-w-[496px]",
          "bg-white overflow-auto",
          "transform transition-transform duration-500 ease-in-out",
          contentClassName
        )}
        {...props}
      >
        <header className="flex justify-between items-center mb-8">
          <Dialog.Title className="text-2xl m-0">{title}</Dialog.Title>
          <Dialog.Close asChild>
            <button>
              <XIcon className="h-6 w-6" aria-hidden="true" />
            </button>
          </Dialog.Close>
        </header>

        {children}
      </Dialog.Content>
    </Dialog.Root>
  );
}

Comments: dev.to