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:
- Not visible right now (
currentState === false
), but should be shown (desiredState === true
): Render the component and setentering
(usually something like 0% opacity, or moved outside of the screen) as the active transition. - Visible right now (
currentState === true
), but should not be shown anymore (desiredState === false
): Set active transition toexiting
(often the same asentering
, so something like 0% opacity, …) and keep the component for now.
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:
- Render with
0%
opacity, and once that is reflected in the DOM, - 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>
);
}
Top comments (0)