👩🏻💻 요구사항: 컨텐츠들이 쭉 있고, 스크롤을 내리다 끝 쯤에 도달하면 한 페이지씩 화면이 전환되는 형태의 인터랙션을 구현하고 싶어요!
Full Page Scroll이란?
Full Page Scroll은 웹사이트에서 스크롤 시 한 페이지 단위로 전체 화면이 넘어가는 패턴이다.
보통 포트폴리오, 이벤트 랜딩 페이지 등에서 자주 보이며, 사용자 시선을 자연스럽게 끌 수 있어 몰입도 높은 UI 연출이 가능하다.
1차 시도: scroll 이벤트 기반 구현
처음엔 직관적으로 wheel, touch 이벤트를 기반으로 구현했다.
각 페이지 섹션의 높이를 기준으로 현재 페이지를 계산하고, 스크롤 방향에 따라 페이지를 전환한다.
import React, { ReactNode } from "react";
import { useEffect, useRef } from "react";
import styles from "./index.module.css";
const FullPageScroll = ({ children }: { children: ReactNode }) => {
const outerDivRef = useRef<HTMLDivElement>(null);
const currentPage = useRef(0);
const canScroll = useRef(true);
const oldTouchY = useRef(0);
const scrollDown = () => {
const pageHeight = outerDivRef.current?.children.item(0)?.clientHeight;
if (outerDivRef.current && pageHeight) {
outerDivRef.current.scrollTo({
top: pageHeight * (currentPage.current + 1),
left: 0,
behavior: "smooth",
});
canScroll.current = false;
setTimeout(() => {
canScroll.current = true;
}, 500);
if (outerDivRef.current.childElementCount - 1 > currentPage.current) {
currentPage.current++;
}
}
};
const scrollUp = () => {
const pageHeight = outerDivRef.current?.children.item(0)?.clientHeight;
if (outerDivRef.current && pageHeight) {
outerDivRef.current.scrollTo({
top: pageHeight * (currentPage.current - 1),
left: 0,
behavior: "smooth",
});
canScroll.current = false;
setTimeout(() => {
canScroll.current = true;
}, 500);
if (currentPage.current > 0) {
currentPage.current--;
}
}
};
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
if (!canScroll.current) {
return;
}
const { deltaY } = e;
if (deltaY > 0 && outerDivRef.current) {
scrollDown();
} else if (deltaY < 0 && outerDivRef.current) {
scrollUp();
}
};
const handleScroll = (e: Event) => {
e.preventDefault();
};
const handleTouchDown = (e: TouchEvent) => {
oldTouchY.current = e.changedTouches.item(0)?.clientY || 0;
};
const handleTouchUp = (e: TouchEvent) => {
const currentTouchY = e.changedTouches.item(0)?.clientY || 0;
const isScrollDown = oldTouchY.current - currentTouchY > 0;
if (isScrollDown) {
scrollDown();
} else {
scrollUp();
}
};
useEffect(() => {
const outer = outerDivRef.current;
if (!outer) {
return;
}
outer.addEventListener("wheel", handleWheel);
outer.addEventListener("scroll", handleScroll);
outer.addEventListener("touchmove", handleScroll);
outer.addEventListener("touchstart", handleTouchDown);
outer.addEventListener("touchend", handleTouchUp);
return () => {
outer.removeEventListener("wheel", handleWheel);
outer.removeEventListener("scroll", handleScroll);
outer.removeEventListener("touchmove", handleScroll);
outer.removeEventListener("touchstart", handleTouchDown);
outer.removeEventListener("touchend", handleTouchUp);
};
}, []);
return (
<div ref={outerDivRef} className={styles["container"]}>
{children}
</div>
);
};
export default FullPageScroll;2차 시도: IntersectionObserver 도입
스크롤 감지를 수동 이벤트 핸들링 대신 브라우저에 위임하는 방식으로 전환했다.
IntersectionObserver를 활용해 특정 영역이 화면에 들어올 때 이벤트를 바인딩하고 해제하는 방식이다.
import React, { ReactNode } from "react";
import { useEffect, useRef, useCallback } from "react";
import styles from "./index.module.css";
const FullPageScroll = ({ children }: { children: ReactNode }) => {
const outerDivRef = useRef<HTMLDivElement>(null);
const currentPage = useRef(0);
const isScrolling = useRef(false);
const lastScrollTime = useRef(0);
const touchStartY = useRef(0);
const scrollToPage = useCallback((pageIndex: number) => {
const pageHeight = outerDivRef.current?.children[0]?.clientHeight;
if (!outerDivRef.current || !pageHeight) {
return;
}
const maxPages = outerDivRef.current.childElementCount - 1;
const newPage = Math.max(0, Math.min(pageIndex, maxPages));
outerDivRef.current.scrollTo({
top: pageHeight * newPage,
left: 0,
behavior: "smooth",
});
currentPage.current = newPage;
isScrolling.current = true;
setTimeout(() => {
isScrolling.current = false;
}, 600);
}, []);
const handleWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault();
const now = Date.now();
if (isScrolling.current || now - lastScrollTime.current < 600) {
return;
}
const deltaY = e.deltaY;
if (deltaY > 50) {
scrollToPage(currentPage.current + 1);
lastScrollTime.current = now;
} else if (deltaY < -50) {
scrollToPage(currentPage.current - 1);
lastScrollTime.current = now;
}
},
[scrollToPage],
);
const handleTouchStart = useCallback((e: TouchEvent) => {
touchStartY.current = e.touches[0].clientY;
}, []);
const handleTouchMove = useCallback((e: TouchEvent) => {
e.preventDefault();
}, []);
const handleTouchEnd = useCallback(
(e: TouchEvent) => {
if (isScrolling.current) {
return;
}
const touchEndY = e.changedTouches[0].clientY;
const deltaY = touchStartY.current - touchEndY;
if (Math.abs(deltaY) > 50) {
if (deltaY > 0) {
scrollToPage(currentPage.current + 1);
} else {
scrollToPage(currentPage.current - 1);
}
}
},
[scrollToPage],
);
useEffect(() => {
const outer = outerDivRef.current;
if (!outer) {
return;
}
const addEventListeners = () => {
outer.addEventListener("wheel", handleWheel, { passive: false });
outer.addEventListener("touchstart", handleTouchStart, {
passive: false,
});
outer.addEventListener("touchmove", handleTouchMove, { passive: false });
outer.addEventListener("touchend", handleTouchEnd, { passive: false });
};
const removeEventListeners = () => {
outer.removeEventListener("wheel", handleWheel);
outer.removeEventListener("touchstart", handleTouchStart);
outer.removeEventListener("touchmove", handleTouchMove);
outer.removeEventListener("touchend", handleTouchEnd);
};
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
addEventListeners();
scrollToPage(0);
} else {
removeEventListeners();
}
},
{ threshold: 0.1 },
);
observer.observe(outer);
return () => {
observer.disconnect();
removeEventListeners();
};
}, [
handleWheel,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
scrollToPage,
]);
return (
<div ref={outerDivRef} className={styles["container"]}>
{children}
</div>
);
};
export default FullPageScroll;왜 이렇게 바꿨나?
- scroll 이벤트를 일일이 핸들링하는 방식은 성능에 불리하고, 컨텍스트를 구분하기 어렵다.
- 특정 영역에서만 스크롤 전환 기능이 동작하도록 하고 싶었기 때문에, 해당 영역이 뷰포트에 진입했을 때만 이벤트를 연결하는 구조가 필요했다.
- 필요할 때만 이벤트를 붙였다가 떼기 때문에 부작용을 줄일 수 있다.
문제점
이 방식은 전체 페이지가 Full Scroll 구조일 때는 잘 작동했지만, 페이지 중간에 Full Page Scroll이 삽입된 경우엔 문제가 생겼다.
-
이미지나 비디오 등 포인터 이벤트가 걸려 있는 요소 위에서는 wheel 이벤트가 부모로 전달되지 않아 페이지 전환이 막힘.
-
빠르게 스크롤할 경우 IntersectionObserver가 아직 뷰포트 안으로 진입하기도 전에 이벤트가 끊기는 현상도 발생.
3차 시도: 더 단순하게, scrollY 기반 제어
고민 끝에 태초마을로 돌아가 생각했고 방향을 바꿨다.
스크롤을 제어하려고 애쓰기보다는 스크롤은 그대로 두고, 특정 시점에 화면이 전환되는 듯한 효과를 주기로 했다.
방식
두 페이지를 position: absolute로 겹쳐 놓고,
스크롤 위치(window.scrollY)가 특정 지점 이상이면 상태값을 변경해 CSS 전환 효과를 주는 방식이다.
처음에는 scrollY > 5200 같은 하드코딩된 값으로 구현해 동작을 확인했다.
const [isActive, setIsActive] = useState(false);
useEffect(() => {
window.addEventListener("scroll", (e) => {
console.log(e.currentTarget.scrollY);
setIsActive(e.currentTarget.scrollY > 5200);
});
}, []);
return (
<section className={styles["scroll-section"]}>
<div className={styles["section-wrapper"]}>
<div
ref={sectionRef}
className={`${styles["section-first"]} ${styles["section-layer"]} ${
isActive ? "" : styles["active"]
}`}
>
<span>1단계 컨텐츠입니다.</span>
<strong>다음 화면으로 넘어갑니다.</strong>
</div>
<div
className={`${styles["section-second"]} ${styles["section-layer"]} ${
isActive ? styles["active"] : ""
}`}
>
<img src="/images/next-step.png" alt="Next Step" />
</div>
</div>
</section>
);.scroll-section {
height: 200vh;
position: relative;
}
.section-wrapper {
position: sticky;
top: 0;
height: 100vh;
}
.section-layer {
position: absolute;
top: 0;
left: 0;
height: 100vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
transition:
opacity 0.4s,
transform 0.4s;
opacity: 0;
transform: translateY(50px);
}
.section-layer.active {
opacity: 1;
transform: translateY(0);
}
.section-first {
background-color: #000;
color: #fff;
text-align: center;
}
.section-first span,
.section-first strong {
font-size: 2rem;
display: block;
margin: 0.5rem 0;
}
.section-second {
background: linear-gradient(to bottom, #00e0ff, #0075ff, #0047ff);
text-align: center;
}scrollY → getBoundingClientRect 개선
하드코드딩된 방식에서 getBoundingClientRect().bottom <= window.innerHeight 를 활용해
해당 요소가 화면 하단에 다다랐을 때 전환이 되도록 바꿨다.
useEffect(() => {
const handleScroll = () => {
if (!finishRef.current) {
return;
}
const rect = finishRef.current.getBoundingClientRect();
const windowHeight = window.innerHeight;
setIsActive(rect.bottom <= windowHeight);
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);결론
처음엔 정석대로 scroll 이벤트를 제어하려 했지만, 오히려 그게 복잡함을 초래했다.
결국은 단순한 방식이 더 나았다.
가장 단순한 접근이 때로는 가장 효과적이다.