21
loading...
This website collects cookies to deliver better user experience
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).react-css-transition-hook
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>
)
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,
];
}
useState
, useEffect
, and useCallback
hooks.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
);
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]);
currentState === false
), but should be shown (desiredState === true
): Render the component and set entering
(usually something like 0% opacity, or moved outside of the screen) as the active transition.currentState === true
), but should not be shown anymore (desiredState === false
): Set active transition to exiting
(often the same as entering
, so something like 0% opacity, …) and keep the component for now.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:0%
opacity, and once that is reflected in the DOM,100%
for the transition to start.useEffect
is for.useEffect(() => {
switch (transition) {
case "entering":
setTransition("entered");
break;
case "exiting":
setTransition("exited");
break;
}
}, [transition]);
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]);
onTransitionEnd
event handler. Once fired, it sets the current state to false
and resets the transition to null
.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>
);
}