ReactJS/NextJS

[NextJS] framer-motion을 활용한 모달(Modal) 구현

썸머워즈 2024. 3. 22. 19:13
반응형

framer-motion을 굳이 사용한 이유는?

React에서 제공하고 있는 Portal기능만을 사용하여 모달창을 구현할 수는 있으나 "Exit Animation"을 주기가 어려웠다.

React환경에서는 show/hide의 상태값을 변경하여 리렌더링 되면, 모달 컴포넌트가 보이거나 사라진다.

하지만 "리렌더링"이 되면서 보이거나 사라지기 때문에 사라질 때는 애니메이션 없이 바로 사라지는 현상이 발생한다.

 

라이브러리도 있는 마당에 어떻게든 구현할 방법은 있겠지만 이미 훌륭하게 제공하고 있는 라이브러리를 굳이 쓰지 않을 이유가 없었다.

그리고 라이브러리 종류 역시 많았는데 그 중에 framer-motion을 선택한 이유는, 어차피 이왕 선택해서 사용하는 거 가장 인기가 많고, 호환성이 좋다고 알려진 라이브러리를 선택하고 싶었다.

https://www.framer.com/motion/

 

Documentation | Framer for Developers

An open source, production-ready motion library for React on the web.

www.framer.com

framer-motion은 Exit Animation을 어떻게 제공하는지?

framer-motion 라이브러리를 사용하면 아주 간단하게 Exit Animation을 구현할 수 있다.

바로 AnimatePresencemotion을 사용하면 된다.

import { motion, AnimatePresence } from "framer-motion";

<AnimatePresence>
  {isVisible && (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
    />
  )}
</AnimatePresence>

일반적으로 이러한 구조를 가지게 되며, isVisible 변수를 이제 useState로 관리한다고 생각하면 된다.

그러면 정의된 이벤트 모션(inital, animate, exit)으로 인해 렌더트리에서 사라질 때 애니메이션 효과를 확인할 수 있다.


자 적용할 라이브러리를 골랐으니 모달 구조를 하나씩 잡아보자.

우선 선행해야 할 부분들이 존재한다.

기본적으로 React의 Portal을 활용해야 하기 때문에 공통으로 사용할 수 있을만한 컴포넌트를 만들어두자.

가장 최상위 레벨에 들어갈 기본 컴포넌트를 구성할 것이다.

 

컴포넌트를 만들기 전에 환경을 대충 설명 하자면

  • Next 13.5 - app router (app dir)
  • Typescript
  • framer-motion
  • Emotion

일단 이 정도로만 알고 진행하면 좋을 거 같다.

그리고 기본적인 내용에 대해서는 되도록 설명을 생략하려고 한다.

Portal.tsx

'use client'

import { createPortal } from 'react-dom'
import useIsClient from '@/hooks/common/useIsClient'

export default function Portal({ children }: { children: any }) {
  const { isClient } = useIsClient()
  
  return (
    isClient && createPortal(children, document.querySelector('#root_container') as HTMLElement)
  )
}

useIsClient.ts

import { useEffect, useState } from 'react'

/**
 * 클라이언트 사이드 렌더링 체크를 위한 훅
 */
const useIsClient = () => {
  const [isClient, setIsClient] = useState(false)

  useEffect(() => {
    setIsClient(true)
  }, [])

  return { isClient }
}

export default useIsClient

Modal.tsx

'use client'

import Portal from '@/components/portal/Portal'
import { AnimatePresence } from 'framer-motion'
import { PropsWithChildren } from 'react'

export default function Modal({ children, isActive }: PropsWithChildren<{ isActive: boolean }>) {
  return (
    <Portal>
      <AnimatePresence>{isActive && children}</AnimatePresence>
    </Portal>
  )
}

자 이렇게 최상위 레벨에서 사용할 만한 파일과 컴포넌트가 만들어졌다.

이제 하위 컴포넌트를 하나씩 구성해 보자.


애니메이션 컴포넌트 (ft. 딤드를 첨가한)

모달 애니메이션을 담당할 컴포넌트를 만들 것이다.

여기서부터는 emotion 문법이 좀 들어가는데,

좀 더 스타일을 유연하게 주기 위해 Emotion을 사용하였다.

 

스타일 코드는 상황에 따라 달라질 수 있어 굳이 첨부하진 않겠다.

ModalTransition.tsx

/** @jsxImportSource @emotion/react */
'use client'

import { motion, MotionProps } from 'framer-motion'
import { PropsWithChildren } from 'react'
import ModalDimmed from '@/components/modal/ModalDimmed'
import {
  ModalTransitionStyles,
  TransitionContainer,
} from '@/components/animation/transition/TransitionStyle'
import { cx } from '@emotion/css'

interface IModalTransitionProps extends MotionProps {
  animation?: any // 커스텀 애니메이션
  isFull?: boolean // 최대치 여부
  isDimmed?: boolean // 딤드 여부
  onClick?: () => void // 딤드 클릭 이벤트
}

export default function ModalTransition({
  children,
  isFull,
  isDimmed,
  onClick,
  animation = { initial: { opacity: 1 } },
  ...props
}: PropsWithChildren<IModalTransitionProps>) {
  return (
    <div css={ModalTransitionStyles} className={cx(isFull && 'isFull', isDimmed && 'isDimmed')}>
      {isDimmed && <ModalDimmed onClick={onClick} />}
      <motion.div
        css={TransitionContainer}
        className={'transitionContainer'}
        {...animation}
        {...props}
      >
        {children}
      </motion.div>
    </div>
  )
}

모달 애니메이션을 주기 위한 컴포넌트이다.

  • animation : framer-motion에서 사용하는 애니메이션 효과를 받는다. (굳이 타입을 옵셔널로 준 이유는 기본적으로 사용되는 애니메이션만 설정해 두면 굳이 props로 계속 전달해 둘 필요가 없기에 애니메이션을 바꾸는 게 아니면 굳이 넣을 필요 없이 구성)
  • isFull : 실제로 사용함에 있어 모달 크기가 최대치를 유지해야 하는 경우가 있기에 props로 boolean값을 받도록 하였다.
  • isDimmed & onClick : 딤드를 활용하기 위한 props 값이다.

여기서 ModalDimmed를 애니메이션 주는 곳에 굳이 같이 넣어둔 이유는 children으로 딤드를 같이 받게 되면 이벤트 옵션을 따라가기 때문이었다.

ModalDimmed의 애니메이션과 ModalTransition의 애니메이션을 독립적으로 가져가야 해서 저렇게 해놨는데,

좀 더 좋은 방법이 있을 수 있을 거 같은데 좀 아쉬운 부분이다.

ModalDimmed.tsx

/** @jsxImportSource @emotion/react */
'use client'

import { motion, MotionProps } from 'framer-motion'
import { ModalDimmedStyle } from '@/components/modal/ModalStyle'

interface IModalDimmedProps extends MotionProps {
  onClick?: () => void
}

export default function ModalDimmed({ onClick, ...props }: IModalDimmedProps) {
  return (
    <motion.div
      css={ModalDimmedStyle}
      className={'dimmed'}
      onClick={() => onClick && onClick()}
      initial={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      {...props}
    />
  )
}

만약 ModalTransition.tsx에 위치한 Dimmed로 원하는 인터렉션을 얻을 수 없다면, 직접 원하는 위치에 ModalDimmed.tsx를 제어하는 것도 나쁘지 않아 MotionProps를 전달받도록 구성해 보았다.

(실제로 구현 후 사용할 때 별도로 사용하는 케이스가 생겨버려 유용하게 사용하였다.)


스타일 구조를 잡기 위한 상위 컴포넌트

단지 스타일을 좀 더 유연하게 관리하기 위한 상위 컴포넌트라고 생각하면 된다.

ModalContainer.tsx

/** @jsxImportSource @emotion/react */
'use client'

import { HTMLAttributes, PropsWithChildren } from 'react'
import { ModalContainerStyle } from '@/components/modal/ModalStyle'

interface IModalContainerProps extends HTMLAttributes<HTMLDivElement> {}

export default function ModalContainer({
  children,
  ...props
}: PropsWithChildren<IModalContainerProps>) {
  return (
    <div css={ModalContainerStyle} className={'modalContainer'} {...props}>
      {children}
    </div>
  )
}

모달의 고정 높이값이 필요할 때 주로 style을 props로 전달하는 거 빼고는 제어할만한 것은 없어 보인다.


자 테스트를 진행해 보자

이제 여기까지 왔다면 실제로 사용해 볼 만한 모달을 구현할 수 있는 수준까지(?) 오지 않았을까 싶다.

회사에서 모바일 환경의 모달창을 만들다 보니 bottom to top의 애니메이션을 적용한 모달창을 테스트로 만들어 본다.

'use client'

import { useState } from 'react'
import Modal from '@/components/modal/Modal'
import ModalTransition from '@/components/animation/transition/ModalTransition'
import { animation } from '@/components/animation/transition/animation'
import ModalContainer from '@/components/modal/ModalContainer'

export default function Page() {
  const [isActive, setIsActive] = useState(false)
  const handleModal = () => setIsActive((prevState) => !prevState)

  return (
    <>
      <button onClick={handleModal}>모달 구현 테스트</button>

      <Modal isActive={isActive}>
        <CustomModal onClick={handleModal} />
      </Modal>
    </>
  )
}

const CustomModal = ({ onClick }: { onClick: () => void }) => {
  return (
    <ModalTransition isDimmed animation={animation.get('btt')} onClick={onClick}>
      <ModalContainer style={{ height: '50vh' }}>testest testetest</ModalContainer>
    </ModalTransition>
  )
}

참고로 여기서 <Modal> <CustomModal /> </Modal>의 구조로 간 이유는 CustomModal의 최초 렌더링을 방지하기 위함이다. 

그리고 animation.get('btt')라고 적힌건 그냥 Map 객체에 btt 관련 애니메이션 정의한 거 가져온거니 필요한 애니메이션 따로 어디 넣어두고 사용하면 좋다.

결과

영상은 첨 올려보네 gif로 할껄 그랬나 싶다.

최종 컴포넌트 구성

위에서 구현한 컴포넌트들만 가지고도 충분히 만들 수 있겠지만, 좀 더 역할 분담을 하고 공통 스타일을 적용하기 위해 몇몇 컴포넌트들을 추가하여 모달 최종 엔트리를 구성해 마무리한다.

  • <Portal> : 실제로 createPortal을 사용하기 위한 컴포넌트
  • <Modal> : <Portal> 컴포넌트 안에 Exit Animation을 활용하기 위한 <AnimatePresence>로 감싼 컴포넌트
    • <ModalTransition> : framer-motion의 애니메이션이 정의된 애니메이션 컴포넌트
      • <ModalContainer> : 모달 구조를 잡기 위한 상위 컴포넌트
        • <ModalHeader> : 모달 헤더 구조를 잡기 위한 헤더 컴포넌트
        • <ModalContents> : 모달 내용 컴포넌트
        • <ModalFooter> : 모달 푸터 구조를 잡기 위한 푸터 컴포넌트

ModalConatiner 안의 세부 컴포넌트의 경우 처음에는 합성 컴포넌트로 만들었었는데 그냥 빼버렸다.


뭔가 아주 재미난 경험이어서 이렇게 정리해두는데,

좀 더 좋은 방법으로 구성할 순 없었을까 하고 욕심이 생긴다.

반응형