해당 프로젝트는
Vanilla JS
에TypeScript
만을 사용한 프로젝트입니다.
직접 스크롤에 의한 애니메이션을 적용하기 위해서는 생각보다 많은 처리가 필요합니다.
아래 코드는 많긴 하지만 최소한으로 작성한 코드입니다.
코드의 실행 흐름
1. 스크롤 이벤트를 실행할 영역의 높이를 정한다.
2. 이벤트를 발생할 엘리먼트/시작지점/끝지점, 시작/끝 값을 정한다.
3. 스크롤 이벤트로 영역에서 스크롤된 비율을 구해서 시작지점/끝지점에 해당하는지 판단하고 그에 맞는 시작/끝 값을 inline-style
로 넣는다.
index.ts
/**
* 현재 애니메이션이 실행될 비율 구하기
* @range 애니메이션 실행 스크롤의 범위 ( 0 ~ 1 )
* @value 애니메이션 실행 값의 범위
* @currentSceneScrollHeight 현재 "scene"에서 스크롤한 크기
* @currentSceneHeight 현재 "scene"의 전체 높이
* @returns 현재 애니메이션 실행 값
*/
const getTimelineValue = ({
range,
value,
currentSceneScrollHeight,
currentSceneHeight,
}: TimelineValueProps) => {
// 애니메이션 시작지점/끝지점/지속영역
const animationStartHeight = currentSceneHeight * range.start;
const animationEndHeight = currentSceneHeight * range.end;
const animationHeight = animationEndHeight - animationStartHeight;
// 애니메이션 실행 영역에 들어온 경우
if (
animationStartHeight <= currentSceneScrollHeight &&
animationEndHeight >= currentSceneScrollHeight
) {
// "((currentSceneScrollHeight - animationStartHeight) / animationHeight)" => 현재 "scene"에서 스크롤된 비율
// "* (value.end - value.start) + value.start"는 지정된 범위로 변환시켜주는 연산 ( 0~1사이의 값(v)이 10~100(value.start, value.end) 사이로 변해야 한다면 "v * ( start - end ) + end"를 해주면 됨 )
return (
((currentSceneScrollHeight - animationStartHeight) / animationHeight) *
(value.end - value.start) +
value.start
);
}
// 애니메이션 실행 영역 이전인 경우
else if (animationStartHeight > currentSceneScrollHeight) {
return value.start;
}
// 애니메이션 실행 영역 이후인 경우
else {
return value.end;
}
};
(() => {
// 모든 "scene"을 찾고 타입 확정
const scenes = [...document.querySelectorAll("#scene")].filter(
(scene): scene is HTMLElement => scene instanceof HTMLElement
);
// "scene0"의 "message"들 ( 스크롤에 의한 애니메이션을 지정할 "element"들 )
const messagesOfScene0 = [
...document.querySelectorAll(".scene-a .message"),
].filter((message): message is HTMLElement => message instanceof HTMLElement);
// 각 "scene"에 대한 정보
const sceneInfos = [
// 첫 번째 "scene"의 정보
{
// "레이아웃에 영향을 미치지 않는 배치"를 의미
type: "fixed",
// "뷰포트 높이의 3배를 가짐"을 의미
heightNumber: 3,
},
// 두 번째 "scene"의 정보
{
// "레이아웃에 영향을 미치는 자연스러운 배치"를 의미
type: "nomal",
heightNumber: 1,
},
// ...
] as const;
// 각 "scene"의 "animation"에 대한 정보
const animationInfos = [
// 첫 번째 "scene"에서 실행할 애니메이션
{
// 애니메이션을 실행할 타겟
messageA: {
// "opacity"
opacityIn: {
// 시작/끝 위치 ( 해당 "scene"의 스크롤 0.1 ~ 0.3 사이에 실행한다는 의미 )
range: { start: 0.1, end: 0.3 },
// 시작/끝 값 ( 값이 0 ~ 1사이로 변한다는 의미 )
value: { start: 0, end: 1 },
},
// 위 값의 예시로 설명하자면 "scene"의 "height"가 1000px이라고 가정했을 때 100px에 위치할땐 0, 200px일땐 0.5, 300px일땐 1의 값을 얻는다는 의미, 그 값은 "getTimelineValue()"을 이용해서 얻음
opacityOut: {
range: { start: 0.7, end: 1 },
value: { start: 1, end: 0 },
},
// "translateY"
translateYIn: {
range: { start: 0.1, end: 0.3 },
value: { start: 60, end: 0 },
},
translateYOut: {
range: { start: 0.7, end: 1 },
value: { start: 0, end: -60 },
},
},
// ... 같은 "scene"의 다른 요소들의 애니메이션 설정값
} as const,
// ... 다른 "scene"의 특정 요소들 애니메이션 설정값
] as const;
// 현재 위치한 "scene" / 이전 "scene"들의 "height"의 합
let currentScene = 0;
let prevSceneHeight = 0;
// 초기 세팅
const init = () => {
// 각 "scene"의 높이 설정하기
sceneInfos.forEach((sceneInfo, i) => {
if (sceneInfo.type === "nomal") return;
// 각 "scene"의 높이를 "현재 브라우저의 높이 * heightNumber"로 지정
scenes[i].style.height = innerHeight * sceneInfo.heightNumber + "px";
});
};
// 현재 어느 "scene"인지 구하는 이벤트 함수 ( + "prevSceneHeight"도 구함 )
const onScrollEvent = () => {
prevSceneHeight = 0;
// 이전 "scene"의 높이의 합
for (let i = 0; i < currentScene; i++) {
prevSceneHeight += scenes[i].clientHeight;
}
// 다음 "scene"으로 넘어가면 실행 ( 바뀌는 시점에 마지막 애니메이션이 적용 안되기 때문에 바뀌기전 마지막에 애니메이션 적용 )
if (scrollY > prevSceneHeight + scenes[currentScene].clientHeight) {
currentScene++;
playAnimation();
}
if (scrollY < prevSceneHeight) {
currentScene--;
playAnimation();
}
};
// 애니메이션 실행
const playAnimation = () => {
// 현재 "scene"의 정보들
const scene = scenes[currentScene];
// 애니메이션의 값을 계산할 때 필요한 현재 "scene"의 정보 ( 현재 "scene"에서 스크롤된 높이 크기 / 현재 "scene"의 전체 높이 / 현재 "scene"에서 스크롤된 높이의 비율 )
// 현재 "scene"에서 스크롤된 높이 크기 ( 전체 스크롤된 높이 - 이전 "scene"들의 높이 )
const currentSceneScrollHeight = scrollY - prevSceneHeight;
// 현재 "scene"의 전체 높이
// "innerHeight"즉 브라우저 높이를 빼준 이유는 "scrollY"가 0일 때도 "innerHeight"만큼 영역을 차지하기 때문... 글로 설명하기 너무 애매해서 "innerHeight"를 빼준 값과 안빼준 값을 비교해서 실행해보면 이해할 수 있음
const currentSceneHeight = scene.clientHeight - innerHeight;
// 현재 "scene"에서 스크롤된 비율 ( 현재 "scene"에서 스크롤한 높이 / 현재 "scene"의 전체 높이 )
const ratio = currentSceneScrollHeight / currentSceneHeight;
// 값을 넣을 변수
let opacity = 0;
// 첫 번째 "scene"
if (currentScene === 0) {
const animationInfo = animationInfos[currentScene];
// "messageA"의 "opacity" 애니메이션
if (animationInfo.messageA.opacityIn.range.end > ratio) {
opacity = getTimelineValue({
...animationInfo.messageA.opacityIn,
currentSceneScrollHeight,
currentSceneHeight,
});
} else {
opacity = getTimelineValue({
...animationInfo.messageA.opacityOut,
currentSceneScrollHeight,
currentSceneHeight,
});
}
messagesOfScene0[0].style.opacity = `${opacity}`;
// ... 다른 애니메이션 설정들
}
};
window.addEventListener("DOMContentLoaded", () => {
init();
});
window.addEventListener("scroll", () => {
onScrollEvent();
playAnimation();
});
})()
평소에 다른분들의 포트폴리오 웹사이트를 구경해보면 대부분 스크롤의 비율에 의해서 애니메이션이 실행되는 구조로 많이 구현한걸 봤었습니다. 저도 다음에 포트폴리오 웹사이트를 만들때는 저런 방식으로 구현해야겠다고 항상 생각했어서 이번에 직접 구현하게 되었습니다.
기존에 스크롤 이벤트를 사용한적은 있지만 이렇게 특정 지점에서 스크롤의 비율에 의해서 애니메이션의 실행의 비율이 결정되는 것을 해본적은 없습니다. 만약 해봤어도 특정 라이브러리를 이용해서 쉽게 구현했을텐데 JavaScript
만을 이용해서 바닥부터 구현하려니 헷갈리고 어려운 부분이 정말 많았습니다.
이렇게 시간이 많이 소모되고 읽기도 힘든 코드를 작성해보는게 도움이 될지는 정확하게 알 수는 없지만 그래도 직접 원리를 이해하고 구현해봤다는 점이 언젠간은 도움이 되지 않을까 생각합니다.