far

[React + Typescript] Modal창 만들기 (+ 스크롤 고정) 본문

React/기록

[React + Typescript] Modal창 만들기 (+ 스크롤 고정)

Eater 2023. 4. 18. 19:04

과거에 만들었던 개인 프로젝트를 보다 보니까 내가 사용하면서도 UX가 안좋다는 생각이 들 정도로 불필요한 링크 이동이 있었다. 그래서 조금이라도 UX적으로 좋게 만들기 위해 간단한 정보들은 링크 이동 대신 모달창을 사용해 정보를 보여주기로 했다.

 

여태까지 모달창은 Bootstrap이나 TailwindCSS에서 등에서 제공하는 걸 사용해 왔는데, 바꾸는 김에 내가 만들어보는 것도 나쁘지 않겠다고 생각해 작업을 해보았다.

 

index.tsx

// 설명하는데 불필요한 코드는 전부 생략했다.
import Project from '../components/Project';
import Aco from '../components/Aco';

export default function Home() {
    const [modal, setModal] = useState(false);
    
    const onModalToggle = useCallback(() => {
        setlModal((prev) => !prev)
    }, [])

    return (
    	<main>
            <div className={`${modal? 'modal-backdrop show' : 'none'}`}>
            	<Aco onModal={onModalToggle} />
            </div>
            <section id="project">
              <Project 
                onModal={onModalToggle} 
              />
            </section>
        </main>
    )
}

modalToggle을 만들어 클릭 버튼이 있는 컴포넌트로 props를 내려준다. 그리고 클릭 버튼을 눌렀을 때 작동하고 싶은 모달창을 CSS로 제어해준다.

 

 

project.tsx

interface props {
    onModal: () => void;
}

const Project = ({ onModal }: props) => {
    return (
        <>
            <div className="project__title">
                <div className="project__title-name">Project</div>
            </div>
            <div className="menu">
                <div className="menu__item">
                    <button
                        className="project__menu__item-link"
                        onClick={onModal}
                    >Aco</button>
                </div>
            </div>
        </>
    )
}

Open 클릭 버튼이 있는 컴포넌트

 

aco.tsx

interface props {
    onModal: () => void;
}

const Aco = ({ onModal }: props) => {
    return (
        <div className="menu">
            <div className="menu__item">
                <button>
                    <i className="bi bi-x" onClick={onModal}></i>
                </button>
            </div>
        </div>
    )
}

Close버튼이 있는 컴포넌트.

 

 

modal.scss

.modal-backdrop.show {
  position: fixed;
  display: flex;
  justify-content: center;
  align-items: center;
  top: 0;
  left: 0;
  z-index: 1050;
  width: 100vw;
  height: 100vh;
  background-color: #ededed89;
}

.modal {
  position: relative;
  border: solid 1px gray;
  z-index: 1055;
  width: 95%;
  height: 95%;
  overflow-x: hidden;
  overflow-y: auto;
  outline: 0;
  background-color: white;
  animation: TranslateIn 0.2s ease-in-out;
}

@keyframes TranslateIn {
  0% {
    transform: translateY(-500px);
    opacity: .5;
  }
  100% {
    transform: translateY(0);
    opacity: 1;
  }
}

.none {
  display: none;
}

부모 엘리트먼트의 .modal-backdrop클래스에서 fixed와 flex로 모달창이 화면 정 중앙에 위치할 수 있도록 자리를 잡아주었고, 하위 엘리먼트인 .modal클래스에서 모달의 크기를 잡아주었다. 또, .modal에 걸 애니메이션과 none클래스가 선택 됐을 때 display를 숨겨주는 CSS를 작성해주었다.

 

여기까지 작성을 해주면 기본적인 모달창은 완성이 된다. 하지만 모달창에 띄울 컨텐츠가 길어져서 스크롤바가 생길 경우, 모달창 내부의 스크롤바가 끝에 위치해 있을 때 마우스 휠을 당기게 되면 전체 창의 스크롤바가 이동되는 현상이 발생했다. 그래서 모달창이 실행될 때 전체 페이지의 스크롤바를 작동 되지 않게 만들기로 했다.

 

 

index.tsx

// 설명하는데 불필요한 코드는 전부 생략했다.
import Project from '../components/Project';
import Aco from '../components/Aco';

export default function Home() {
    const [modal, setModal] = useState(false);
    
    let currentScroll = 0;
    const lockScroll = useCallback(() => {
        currentScroll = window.scrollY;
        document.body.style.overflowY = 'hidden';
        document.body.style.top = `${currentScroll}px`;
    }, [currentScroll]);
    
    const openScroll = useCallback(() => {
        document.body.style.removeProperty('overflow');
        document.body.style.removeProperty('top');
        window.scrollTo(0, currentScroll)
    }, [currentScroll]);
    
    const onModalToggle = useCallback(() => {
        setlModal((prev) => !prev)
        if (!modal) {
          lockScroll()
        } else {
          openScroll()
        }
    }, [modal, lockScroll, openScroll])

    return (
    	<main>
            <div className={`${modal? 'modal-backdrop show' : 'none'}`}>
            	<Aco onModal={onModalToggle} />
            </div>
            <section id="project">
              <Project 
                onModal={onModalToggle} 
              />
            </section>
        </main>
    )
}

document.body를 직접 찍고 싶지는 않았지만 대체할 수 있는 방법을 찾지 못하여 이대로 작성하게 되었다.

일단 scrollY의 위치를 기억해두고 모달 open시 body의 scroll을 없애버린 뒤 close를 눌렀을 때 프로퍼티를 삭제해서 기억해둔 스크롤의 위치로 이동하는 코드를 추가해주었다. style.top은 이게 없으면 애니메이션 작동시 전체 페이지의 스크롤이 0, 0위치로 이동한 것이 보이기 때문에 추가해주었다.

 

하지만 이렇게 작성할 경우 스크롤바가 사라졌을 때 전체 페이지가 스크롤바가 사라진 만큼 움직이게 되어 조금 거슬리는 모양새가 된다.

// 설명하는데 불필요한 코드는 전부 생략했다.
import Project from '../components/Project';
import Aco from '../components/Aco';

export default function Home() {
    const [modal, setModal] = useState(false);
    
    let currentScroll = 0;
    const lockScroll = useCallback(() => {
        currentScroll = window.scrollY;
        document.body.style.overflowY = 'scroll';
        document.body.style.position = 'fixed';
        document.body.style.width = '100%';
        document.body.style.top = `${currentScroll}px`;
    }, [currentScroll]);
    
    const openScroll = useCallback(() => {
        document.body.style.removeProperty('overflow');
        document.body.style.removeProperty('position');
        document.body.style.removeProperty('width');
        document.body.style.removeProperty('top');
        window.scrollTo(0, currentScroll)
    }, [currentScroll]);
    
    const onModalToggle = useCallback(() => {
        setlModal((prev) => !prev)
        if (!modal) {
          lockScroll()
        } else {
          openScroll()
        }
    }, [modal, lockScroll, openScroll])

    return (
    	<main>
            <div className={`${modal? 'modal-backdrop show' : 'none'}`}>
            	<Aco onModal={onModalToggle} />
            </div>
            <section id="project">
              <Project 
                onModal={onModalToggle} 
              />
            </section>
        </main>
    )
}

그래서 어쩔 수 없이 overflow: scroll과 fixed를 사용해 스크롤바를 잠궈버리고 width가 변하지 않게 만들었다.

이제 모달창을 껐다 켜도 거슬리는 부분 없이 잘 동작한다.

 

 

좀 더 좋은 코드가 있을 것 같다는 생각이 드는 작업이었지만.. 어쨋든 완성이다.

Comments