DEV Community

Cover image for Look How I Created Animated Generic Modal (Next.js, Zustand, Motion)
Harish Kumar
Harish Kumar

Posted on

Look How I Created Animated Generic Modal (Next.js, Zustand, Motion)

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;
Enter fullscreen mode Exit fullscreen mode

🧩 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 {}
Enter fullscreen mode Exit fullscreen mode

⚙️ 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 });
  },
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

🧠 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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

✅ 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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

🧩 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;
Enter fullscreen mode Exit fullscreen mode


🎉 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)