Hey Techies 👋,
What’s up…!
I’ve been working on my personal project to explore more on Next.js, Zustand, and Motion.
And today, I ran into one of those “every app needs this” moments —
Oh, I need a modal.
A common one.
A reusable one.
And with cool enter and exit animations.One more thing — it should be architecturally perfect too. 😎
🧱 Store Setup
I configured a Zustand store to handle which modal is open, what props it needs, and the open/close logic.
File Structure
I broke the Zustand logic into three files — a clean, scalable approach:
-
index.ts
→ configures the store and initializes the state -
types.ts
→ holds state and action types -
actions.ts
→ contains store actions (open/close modal)
🗂️ store/modal/index.ts
import { create } from "zustand";
import { ModalState, ModalStore } from "./types";
import { modalActions } from "./actions";
const initialState: ModalState = {
type: null,
props: {},
};
const useModalStore = create<ModalStore>((set, get, store) => ({
...initialState,
...modalActions(set, get, store),
}));
export default useModalStore;
🧩 store/modal/types.ts
export type AvailableModals = "confirm";
export interface ModalState {
type: AvailableModals | null;
props: Record<any, any>;
}
export interface ModalActions {
openModal: (type: AvailableModals) => void;
closeModal: () => void;
}
export interface ModalStore extends ModalState, ModalActions {}
⚙️ store/modal/actions.ts
import { StateCreator } from "zustand";
import { ModalActions, ModalStore } from "./types";
export const modalActions: StateCreator<ModalStore, [], [], ModalActions> = (
set
) => ({
openModal: (type) => {
set({ type });
},
closeModal: () => {
set({ type: null });
},
});
Well, well, well — the store is done and dusted!
Now we need to handle the UI and connect it to this state… and of course, add some animation magic. ✨
🧱 Generic Modal Component
I used React Portal, which not only looks cleaner but also saves you from fighting with z-index and layout stacking nightmares.
Here’s a custom hook to get the portal target from the DOM 👇
🔧 lib/hooks/usePortalTarget.tsx
import { useEffect, useState } from "react";
const usePortalTarget: (elementId: string) => Element | null = (elementId) => {
const [mounted, setMounted] = useState(false);
const [element, setElement] = useState<Element | null>(null);
useEffect(() => {
setElement(document.getElementById(elementId));
setMounted(true);
}, [elementId]);
if (!mounted || !element) return null;
return element;
};
export default usePortalTarget;
🧠 components/modal/BaseModal.tsx
import { PropsWithChildren } from "react";
import { createPortal } from "react-dom";
import { motion } from "motion/react";
import usePortalTarget from "@/lib/hooks/usePortalTarget";
import Backdrop from "../UI/Backdrop";
interface BaseModalProps extends PropsWithChildren, ModalProps {}
const BaseModal: React.FC<BaseModalProps> = ({ onClose, children }) => {
const target = usePortalTarget("modal");
if (!target) return null;
const modalElement = (
<>
<motion.div
animate={{ translateY: ["-30%", "-70%"], opacity: [0, 1] }}
exit={{ translateY: ["-70%", "-30%"], opacity: [1, 0] }}
className="z-10 absolute top-1/2 left-1/2 -translate-x-1/2"
>
{children}
</motion.div>
<Backdrop onClose={onClose} />
</>
);
return createPortal(modalElement, target);
};
export default BaseModal;
Here, I used motion.div
from Motion to animate the modal’s entry and exit transitions.
🪄 Connecting the Modal to the App
Now we need to connect our modal globally to the Next.js app.
I created a provider for that:
⚙️ lib/providers/modal-provider.tsx
"use client";
import { PropsWithChildren, useMemo } from "react";
import { AnimatePresence } from "motion/react";
import ConfirmModal from "@/components/modal/ConfirmModal";
import useModalStore from "../store/modal";
import { AvailableModals } from "../store/modal/types";
const modalMap: Record<AvailableModals, React.FC<ModalProps & any>> = {
confirm: ConfirmModal,
};
interface ModalProviderProps extends PropsWithChildren {}
const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
const { type, props: modalProps, closeModal } = useModalStore();
const ModalToRender = useMemo(() => (type ? modalMap[type] : null), [type]);
return (
<>
{children}
<AnimatePresence mode="wait">
{ModalToRender && (
<ModalToRender key={type} {...modalProps} onClose={closeModal} />
)}
</AnimatePresence>
</>
);
};
export default ModalProvider;
✅ I added a modalMap
object to register modals.
✅ Used AnimatePresence
to handle entry/exit animations.
🌐 Adding to Layout
Now, let’s connect it in the Next.js layout.
🏗️ app/dashboard/layout.tsx
import { Metadata } from "next";
import { Inter } from "next/font/google";
import { PropsWithChildren } from "react";
import { AuthStoreProvider } from "@/lib/providers/auth-store-provider";
import ModalProvider from "@/lib/providers/modal-provider";
import AuthGuard from "@/components/auth/AuthGuard";
import AppLayout from "@/layouts/AppLayout";
import "@/app/globals.css";
const inter = Inter();
export const metadata: Metadata = {
title: "Taskify",
description:
"A simple and powerful task management app to organize your work, track progress, and boost productivity every day.",
};
interface DashboardLayoutProps extends PropsWithChildren {}
const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children }) => (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<AuthStoreProvider>
<ModalProvider>
<AuthGuard mode="auth">
<AppLayout>{children}</AppLayout>
</AuthGuard>
</ModalProvider>
</AuthStoreProvider>
{/* Decoupled elements for rendering modals and backdrops through portals */}
<div id="modal"></div>
<div id="backdrop"></div>
</body>
</html>
);
export default DashboardLayout;
Those two <div>
s might look weird, but trust me — they’re lifesavers.
That’s where our modals and backdrops get rendered via portals, without disturbing the main layout.
🎭 Backdrop Component
Here’s the reusable Backdrop component (I reused it for the mobile sidebar too 👀).
components/UI/Backdrop.tsx
import { Ref } from "react";
import { motion } from "motion/react";
import { createPortal } from "react-dom";
import usePortalTarget from "@/lib/hooks/usePortalTarget";
interface BackdropProps {
onClose: () => void;
ref?: Ref<any>;
}
const Backdrop: React.FC<BackdropProps> = ({ onClose }) => {
const target = usePortalTarget("backdrop");
if (!target) return null;
return createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute top-0 left-0 w-full h-screen bg-black/30"
onClick={onClose}
/>,
target
);
};
export default Backdrop;
🧩 Confirm Modal Example
Finally, let’s create an example Confirm Modal to see it all in action.
components/modal/ConfirmModal.tsx
import Button from "../UI/Button";
import BaseModal from "./BaseModal";
interface ConfirmModalProps extends ModalProps {
onConfirm: () => void;
}
const ConfirmModal: React.FC<ConfirmModalProps> = ({ onClose }) => {
return (
<BaseModal onClose={onClose}>
<div className="bg-white rounded-lg min-w-md">
<h2 className="text-xl p-4 text-center">Confirm</h2>
<hr className="text-gray-400" />
<div className="p-4 min-h-[160px] flex flex-col justify-between">
<p>Do you want to delete this task?</p>
<div className="flex gap-4 justify-center">
<Button size="md" variant="secondary-dark" onClick={onClose}>
Cancel
</Button>
<Button size="md" onClick={onClose}>
Confirm
</Button>
</div>
</div>
</div>
</BaseModal>
);
};
export default ConfirmModal;
🎉 Conclusion
That’s it!
We now have a nice, animated, reusable modal system you can use across your entire app.
Feel free to connect with me on LinkedIn for suggestions, feedback, or just to say “Hi” 👋
GitHub Repo: https://github.com/harish-20/Taskify
LinkedIn: https://www.linkedin.com/in/harish-kumar-418a47237/
Top comments (0)