본문 바로가기

개발 공부/React

custom modal 만들기

출처: https://github.com/Yundimin/next15-boilerplate

 

GitHub - Yundimin/next15-boilerplate: next15-boilerplate with pandacss

next15-boilerplate with pandacss. Contribute to Yundimin/next15-boilerplate development by creating an account on GitHub.

github.com

 

  • src/app/layout.tsx — 루트 레이아웃에서 전역 모달 렌더링 지점 추가
  • src/components/common/modal.tsx — 전역 모달 렌더러 (컨텍스트의 열린 모달 목록을 실제 DOM으로 그림)
  • src/constants/modal-key-constants.ts — 모달 식별 키 상수
  • src/constants/motion-constants.ts — 프레이머 모션 프리셋(슬라이드/페이드/팝/드로어/드롭다운)
  • src/contexts/core-provider.tsx — ModalProvider를 코어 프로바이더 트리에 포함
  • src/contexts/modal-provider.tsx — 전역 모달 상태 컨텍스트 (열기/닫기/라우트 변경 시 초기화)
  • src/hooks/use-modals.ts — openModal, closeModal 

전체 흐름

  1. 컨텍스트 준비 (ModalProvider)
    • 전역에서 열려 있는 모달 배열 openedModals를 관리합니다.
    • openModal({ id, component })로 푸시, closeModal(id)로 제거.
    • 라우트 뒤로가기(popstate) 시 모달 초기화, 모달 열릴 땐 document.body.style.overflow = 'hidden' 처리.
  2. 전역 렌더러 (components/common/Modal)
    • 컨텍스트의 openedModals를 순회 렌더링.
    • Dimmed(검은 배경) 클릭 시 해당 id로 닫기.
    • 모달 컨텐츠는 openModal 호출 시 넘긴 component를 그대로 사용.
    • MOTION 상수 import(프리셋 제공) — 커밋 시점에는 구조만 넣어두고, 실제 변이 적용은 이후 확장 여지.
  3. 루트 레이아웃에서 한 번만 삽입 (app/layout.tsx)
    • <CoreProvider> 트리 안쪽에 <Modal />를 추가하여 어디서든 모달을 띄우면 이 자리에서 렌더되게 함.
  4. 페이지에서 열기 (app/users/page.tsx)
    • useModals().openModal({ id, component }) 호출.
    • 이때 component로 실제 렌더링할 리액트 노드(여기서는 UserCreateFormModal)를 넘김.
  5. 폼 내부에서 닫기 (components/user/user-create-form.tsx)
    • useCreateUserMutation 성공 콜백에서 closeModal(MODAL.USER_CREATE) 호출.
    • 실패 시 react-hook-form의 setError로 필드 에러 표기.

 

1) Modal Context 생성 : src/contexts/modal-provider.tsx

'use client';

import React, { createContext, useCallback, useEffect, useState } from 'react';

export interface ModalComponentType {
  id: React.Key;
  component: React.ReactNode;
}

interface ModalContextType {
  openedModals: ModalComponentType[];
  openModal: (modalComponent: ModalComponentType) => void;
  closeModal: (id: React.Key) => void;
}

interface ModalContextProps {
  children: React.ReactNode;
}

export const ModalContext = createContext<ModalContextType>({} as ModalContextType);

const ModalProvider: React.FC<ModalContextProps> = ({ children }) => {
  const [openedModals, setOpenedModals] = useState<ModalComponentType[]>([]);

  const openModal = useCallback((props: ModalComponentType) => {
    setOpenedModals((modals) => {
      return [...modals, { ...props }];
    });
  }, []);

  const closeModal = useCallback((id: React.Key) => {
    setOpenedModals((modals) => {
      return modals.filter((modal) => modal.id !== id);
    });
  }, []);

  useEffect(() => {
    document.body.style.overflow = openedModals.length > 0 ? 'hidden' : 'auto';
    return () => {
      document.body.style.overflow = 'auto';
    };
  }, [openedModals]);

  useEffect(() => {
    const handleRouteChange = () => {
      setOpenedModals([]);
    };

    window.addEventListener('popstate', handleRouteChange);
    return () => {
      window.removeEventListener('popstate', handleRouteChange);
    };
  }, []);

  return (
    <ModalContext.Provider
      value={{
        openedModals,
        openModal,
        closeModal,
      }}
    >
      {children}
    </ModalContext.Provider>
  );
};

export default ModalProvider;

2) 전역 렌더러: src/components/common/modal.tsx

'use client';

import { MOTION } from '@/constants/motion-constants';
import { ModalContext } from '@/contexts/modal-provider';
import useModals from '@/hooks/use-modals';
import { sva } from '@/styled-system/css';
import { Box } from '@/styled-system/jsx';
import { motion } from 'motion/react';
import { useContext, useEffect } from 'react';

const Modal = () => {
  const modalStyle = ModalSva();
  const { openedModals } = useContext(ModalContext);
  const { closeModal } = useModals();

  useEffect(() => {
    document.body.style.overflow = openedModals.length > 0 ? 'hidden' : 'auto';
  }, [openedModals]);

  return (
    <>
      {openedModals.map((modal, index) => {
        const { id, component } = modal;

        return (
          <Box className={modalStyle.wrapper} key={index}>
            <Box className={modalStyle.dimmed} onClick={() => closeModal(id)} />
            <motion.div className={modalStyle.modal} {...MOTION.POP}>
              {component}
            </motion.div>
          </Box>
        );
      })}
    </>
  );
};

export default Modal;

const ModalSva = sva({
  slots: ['wrapper', 'dimmed', 'modal'],
  base: {
    wrapper: {
      position: 'fixed',
      left: 0,
      top: 0,
      zIndex: 10,
      display: 'flex',
      height: '100dvh',
      width: '100vw',
      alignItems: 'center',
      justifyContent: 'center',
      overflow: 'hidden',
    },
    dimmed: {
      position: 'fixed',
      top: 0,
      left: 0,
      width: '100dvw',
      height: '100dvh',
      overflow: 'hidden',
      backgroundColor: 'black',
      opacity: 0.48,
    },
    modal: {
      position: 'relative',
    },
  },
});

3) 모션 프리셋: src/constants/motion-constants.ts

import { HTMLMotionProps } from 'framer-motion';
export type MotionType = 'SLIDE' | 'FADE' | 'POP' | 'DRAWER' | 'DROPDOWN';

export const MOTION: Record<MotionType, HTMLMotionProps<'div'>> = {
  SLIDE:   { initial: { opacity: 0, y: '50%' },  animate: { opacity: 1, y: 0, transition: { bounce: 0, duration: 0.3 } } },
  FADE:    { initial: { opacity: 0 },           animate: { opacity: 1 }, exit: { opacity: 0 } },
  POP:     { initial: { opacity: 0, y: '100%' }, animate: { opacity: 1, y: 0, transition: { bounce: 0, duration: 0.2 } }, exit: { opacity: 0, y: '100%' } },
  DRAWER:  { initial: { opacity: 0, x: '100%' }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: '100%' }, transition: { bounce: 0, duration: 0.2 } },
  DROPDOWN:{ initial: { opacity: 0, height: 0 },  animate: { opacity: 1, height: 'auto' }, exit: { opacity: 0, height: 0 }, transition: { duration: 0.1 } },
};

4) 루트 배치: src/app/layout.tsx

import './globals.css';
import Modal from '@/components/common/modal';
import CoreProvider from '@/contexts/core-provider';
// ... 생략

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ko">
      <body>
        <CoreProvider>
          <Modal />
          {children}
        </CoreProvider>
      </body>
    </html>
  );
}

 

그리고 src/contexts/core-provider.tsx에서 ModalProvider를 트리에 포함시킵니다.

import AuthProvider from './auth-provider';
import ModalProvider from './modal-provider';
import { QueryProvider } from './query-provider';
import SessionProvider from '@/contexts/session-provider';

const CoreProvider = ({ children }: { children: React.ReactNode }) => (
  <SessionProvider>
    <QueryProvider>
      <AuthProvider>
        <ModalProvider>{children}</ModalProvider>
      </AuthProvider>
    </QueryProvider>
  </SessionProvider>
);

5) 실제 사용: src/app/users/page.tsx

'use client';
import UserCreateForm from '@/components/user/user-create-form';
import Button from '@/components/common/button';
import useModals from '@/hooks/use-modals';
import { useUsers } from '@/lib/user';

const Page = () => {
  const { openModal } = useModals();
  const { data: users } = useUsers({ _page: 1, _per_page: 10 });

  const handleClickCreateButton = () => {
    openModal({
      id: 'user-create-form-modal',
      component: <UserCreateForm />,
    });
  };

  return (
    <>
      {/* 리스트 */}
      {users?.data.map((user) => (
        <div key={user.id}>{user.name}</div>
      ))}

      {/* 모달 열기 */}
      <Button onClick={handleClickCreateButton}>Create</Button>
    </>
  );
};

export default Page;

 

 

6) 폼에서 닫기: src/components/user/user-create-form.tsx

import Button from '@/components/common/button';
import { MODAL } from '@/constants/modal-key-constants';
import useModals from '@/hooks/use-modals';
import { useForm, Controller } from 'react-hook-form';
import { useCreateUserMutation } from '@/lib/user';

const UserCreateFormModal = () => {
  const { closeModal } = useModals();
  const { handleSubmit: formHandleSubmit, formState, control, setError } = useForm({
    defaultValues: { name: '' },
  });
  const { mutate: createUser } = useCreateUserMutation();

  const handleSubmit = formHandleSubmit(async (data) => {
    createUser(data, {
      onSuccess: () => closeModal(MODAL.USER_CREATE),
      onError: (error) => setError('name', { type: 'manual', message: error.message }),
    });
  });

  return (
    <form onSubmit={handleSubmit}>
      <Controller
        name="name"
        control={control}
        render={({ field }) => <input {...field} placeholder="name" />}
      />
      {formState.errors.name && <span>{formState.errors.name.message}</span>}
      <Button type="submit">Submit</Button>
    </form>
  );
};

export default UserCreateFormModal;

'개발 공부 > React' 카테고리의 다른 글

Clerk 인증 정보와 Prisma User 테이블 동기화(sync)  (0) 2025.11.06
useSearchParams hook 만들기  (1) 2025.10.22
React App Router, Page Route  (0) 2025.10.19
Server Components, Client Components  (0) 2025.09.03
useOptimistic  (0) 2025.08.28