I’m working on a Remix app and I wanted to add a modal with its own route. I was already using Framer Motion for bunch of animations in the project and I wanted to use it for the modals (opening and closing animations) as well.
I found a few discussions in the Remix discord and GitHub discussions, but most of the solutions didn’t provide a way for the exit animations to trigger and complete before closing the modal.
So this is what I did.
First I’ll show you the modal (I’ve omitted parts of the code that are not relevant for brevity, link to the full source code in the end of the post):
// /app/components/Modal.tsx
export default function Modal(
props: React.PropsWithChildren<{
title?: string;
isDisabled?: boolean;
onDismiss: () => void;
}>
) {
// ...bunch of code that is omitted handling Esc, UI jittering, clicking outside etc.
const { onDismiss, isDisabled } = props;
function handleDismissClick() {
// You should use this function also for pressing esc
onDismiss();
}
return (
<>
{createPortal(
<>
{/* Overlay */}
<div className="fixed inset-0 z-30">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.75 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0 bg-zinc-700"
/>
</div>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-40 flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div
className="z-50 w-full overflow-hidden rounded bg-white text-zinc-900 shadow-md dark:bg-zinc-800 dark:text-zinc-200 sm:max-w-lg"
>
<div className="flex items-center border-b px-5 py-4 dark:border-zinc-700">
<h3
className="flex-1 text-lg font-bold leading-6 tracking-tight"
id="modal-title"
>
{props.title}
</h3>
{!isDisabled && (
<button
onClick={handleDismissClick}
className="text-zinc-500 outline-purple-500 hover:text-purple-500"
>
<XMarkIcon className="h-4 w-4" />
</button>
)}
</div>
<div>{props.children}</div>
</div>
</motion.div>
</>,
document.getElementById("modal-container") as Element
)}
</>
);
}
That’s a lot of code, but it’s mostly styling (Tailwindcss in this case) and structure, your modal could be simpler. The important thing to note are the <motion.div>
components and their animation props, for both the overlay and the modal itself. For more info on those, check out the Framer Motion docs, but just very briefly you have the initial
prop which basically defines what’s the initial animation state, the animate
prop which defines what the animation should transition to, and the exit
prop which is the exit transition from the animate
state to exit
. You also have a transition
prop in which you can define how long your animation duration should be.
So now that the modal is defined, here is the actual Remix stuff:
// /app/routes/hello.tsx -> our /hello route
import { Link, Outlet } from "@remix-run/react";
export default function Index() {
return (
<div>
<h1 className="text-3xl font-bold">So I heard you like modals?</h1>
<div className="mt-4">
<Link
to="/hello/edit"
replace={true}
className="underline text-purple-600 hover:text-purple-700"
>
Open a Modal
</Link>
</div>
<Outlet />
</div>
);
}
Here I have a Remix Link
component that points to /hello/edit
, I’ve also set the replace
prop to true
so that I don’t polute my browser history, but that’s really up to you and your scenario. In addition we have an Outlet
component that will handle the modal as a nested route (for more info check the remix docs).
Next we have the /hello/edit
route as follows:
// /app/routes/hello/edit
import { useState } from "react";
import { useNavigate } from "@remix-run/react";
import { AnimatePresence } from "framer-motion";
// Our modal from earlier
import Modal from "~/components/Modal";
export default function Edit() {
const [isModalOpen, setIsModalOpen] = useState(true);
const navigate = useNavigate();
function handleDismiss() {
setIsModalOpen(false);
}
function handleExitComplete() {
navigate("/hello", { replace: true });
}
return (
<AnimatePresence onExitComplete={handleExitComplete}>
{isModalOpen && (
<Modal title="My modal" onDismiss={handleDismiss}>
<div className="space-y-4 px-5 py-4">Check out the url</div>
</Modal>
)}
</AnimatePresence>
);
}
The important bits here are the AnimatePresence
, this is a Framer Motion component that allows components to animate out when they’re removed from the React tree. We also set its onExitComplete
prop, which will trigger once the component within AnimatePresence
is removed, we then navigate back to /hello
(the parent route) and again prevent storing this navigation in the browser history.
That’s it, not we have a modal with its own route and both enter and exit animations.
You can find the full source code of this example here.