마이너리 웹 구현 중 디자이너분이 스크롤시 이미지 뱃지에
토스 사이트와 동일한 애니매이션을 적용하기를 원하셔서 만들게 되었다.

먼저 레퍼런스로 주신 토스 사이트를 개발자 도구로 살펴보니 스크롤값에 따라서 opacity 가 변화하는식으로 동작하고 있었다. 따라서, 간단하게 useRef 와 getBoundingClientRect() 를 사용하여 스크롤 이벤트가 발생할 때마다 각각의 뱃지 컴포넌트의 opacity 를 계산하여 스타일로 넣어주었다.
getBoundingClientRect() 메서드는 DomRect 의 요소 크기와 브라우저 뷰 포트에대한 상대적인 위치 정보를 제공하는 객체를 반환하므로 해당 Dom의 width, height, left, top, right, bottom, x, y 를 알려주기 때문에 해당 메서드를 사용하여 돔 정보를 얻어내고 그에 따라 opacity를 조정해주면 될 것 같았다.
처음 시도한 코드
useEffect(() => {
const handler = () => {
const y = document.documentElement.scrollTop;
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
if (rect.y > 400) {
setOpacity(0);
}
setOpacity(rect.y < 0 ? 1 : rect.y > rect.height / 2 ? 0 : rect.y * 0.15);
};
window.addEventListener("scroll", handler);
return () => {
window.removeEventListener("scroll", handler);
};
}, []);
const excelerator = (exel, opacity) => {
return exel / opacity;
};
<S.IcoImg style={{ opacity: Math.min(1, excelerator(1, opacity)) }}>
<img src="/images/section/section3Ico1.png" />
</S.IcoImg>
<S.IcoImg style={{ opacity: Math.min(1, excelerator(10, opacity)) }}>
<img src="/images/section/section3Ico2.png" />
</S.IcoImg>
하지만 opacity 값이 정확히 계산되지 않아 애니매이션이 토스 사이트처럼 자연스럽지 않다는 문제가 발생했다.
opacity 가 스크롤 값에 따라 변하기는 하나 원하던 애니매이션 모션은 아니다. 각각의 뱃지 컴포넌트에 가중치가 제대로 들어가지 않았다.
또한 스크롤위치에 따라서 opacity 가 0 이었다가 1로 변화해야 하는데 이 부분도 제대로 동작하지 않았다.

애니매이션 동작을 구현해본 적이 처음이라.. 다양한 레포를 둘러보니 Framer Motion 을 많이 사용하고 토스 또한 Framer Motion 라이브러리를 사용하여 구현된 듯 했다.
잠깐 Framer Motion 를 사용해볼까 했지만 유료 라이브러리이기 때문에… 스킵하고
인터랙션 웹을 어떻게 구현하는지 좀 배워야겠다 싶어 인프런의 애플 웹사이트 인터랙션 클론! 을 보고 적용시켰다.
강의를 보고 이전 코드에선
- 애니매이션 값들이 정확한 가중치로 계산되지 않음
- 스크롤 위치, 사용자 화면 높이, 섹션의 스크롤 위치 등을 고려하지 않음
과 같은 문제로 인해 정확한 애니매이션이 동작하지 않고 있음을 알게 되었다.
강의를 보니 애니매이션 요소에 넣을 값들을 객체로 관리하고 있었고 이를 통해서 가중치를 계산한다는 것을 알 수 있었다. 따라서 모든 값들을 json 객체로 분리했다.
export const THIRD_CONTENTS = {
title: "와인을 기록하는 순간, <br/> 어떤 뱃지를 받게 될까요?",
subTitle:
"차곡차곡 쌓이는 와인일기와 함께 <br/> 마이뱃지를 수집하는 재미도 느껴보세요",
imgData: [
{
type: "icon",
img: "/images/section/section3Ico1.png",
width: "140",
height: "180",
info: [0, 1, { start: 0.88, end: 0.9 }],
},
그리고 객체 값들을 가져와서 이미지를 만들어 주었고 motion을 주는 함수들은 커스텀 훅으로 분리했다. 해당 섹션의 Wrapper 에 ref 를 사용하여 사용할 Dom 정보를 가져오고 IcoImg 의 style 에서 opacity 를 각각 계산해서 부여해주면 된다.
calcValue 함수는 item의 애니매이션 정보와 현재 섹션의 스크롤 위치 값을 받는다.
const ThirdSection = () => {
const { title, subTitle, imgData } = THIRD_CONTENTS;
const { content, currentYOffset, calcValue } = useMotion();
return (
<Wrapper ref={content}>
<Title dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(title) }} />
<ImgWrapper>
{imgData.map((item, index) => {
return (
<IcoImg
style={{ opacity: calcValue(item.info, currentYOffset) }}
key={index}
>
<Image
src={item.img}
width={+item.width}
height={+item.height}
alt={item.img}
/>
</IcoImg>
);
})}
</ImgWrapper>
<Desc
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(subTitle) }}
/>
</Wrapper>
);
};
UseMotion 훅
현재 스크롤의 위치, 사용자의 화면 높이, content dom의 시작 위치, content dom의 높이 값을 필요로 한다.
이후
- 섹션의 스크롤 위치를 구하고
- 스크롤 위치가 섹션안에 들어온 경우 현재 섹션을 Active로 설정
- 현재 Yoffset이 각 배지의 스크롤시점 안에 들어온 경우 섹션마다 스크롤이 얼마나 되었는지 비율로 계산하여 calcedValue를 구해준다.
개선된 코드
import { useState, useEffect, useRef } from "react";
export const useMotion = () => {
const content = useRef(null);
const [isActive, setIsActive] = useState(false);
const [currentHeight, setCurrentHeight] = useState(0);
const [currentYOffset, setCurrentYOffset] = useState(0);
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const handleScroll = () => {
let scrollTop = document.documentElement.scrollTop; //스크롤 위치
let margin = document.body.clientHeight; //사용자 화면 높이
let currentStart = content.current.offsetTop;
let height = content.current.offsetHeight;
setCurrentHeight(height + 300); //보정 값
// 섹션의 스크롤 위치 (스크롤에서 prev 빼주기가 안되서 섹션시작점을 뺀 후 화면 높이를 대신 더해줌)
setCurrentYOffset(scrollTop - currentStart + margin);
//스크롤 위치가 섹션안에 들어온 경우 현재 섹션을 Active로 설정한다.
if (scrollTop > currentStart - margin) {
if (!isActive) setIsActive(true);
}
};
const calcValue = (values, currentYOffset) => {
let calcedValue = 0;
const partScrollStart = values[2].start * currentHeight;
const partScrollEnd = values[2].end * currentHeight;
const partScrollHeight = partScrollEnd - partScrollStart;
const scrollRatio = currentYOffset / currentHeight;
// 스크롤이 섹션 안에 들어와있고
// 현재 Yoffset이 각 배지의 스크롤시점 안에 들어온 경우
if (isActive === true) {
if (
currentYOffset >= partScrollStart &&
currentYOffset <= partScrollEnd
) {
//섹션마다 스크롤이 얼마나 되었는지 비율로 계산하여 calcedValue를 구한다.
// (현재 위치 - 시작점 / 총 높이 ) * 애니매이션 값
calcedValue =
((currentYOffset - partScrollStart) / partScrollHeight) *
(values[1] - values[0]) +
values[0];
}
// 스크롤 범위가 90% 이상이면 opacity 값을 1 로 설정한다.
if (scrollRatio > 0.9) {
calcedValue = 1;
}
}
return calcedValue;
};
return {
isActive,
content,
currentHeight,
setCurrentHeight,
currentYOffset,
setCurrentYOffset,
calcValue,
};
};
전보다 훨씬 정확한 애니매이션 동작을 구현할 수 있었다. 클라이언트의 높이도 고려해주었기 때문에 모바일 반응형에서도 잘 동작한다 :>
