라틴어로 작은 강을 뜻하는 Rivus. 애니메이션 라이브러리 GSAP의 ScrollTrigger를 Vanilla Javascript로 구현했습니다.
Demo Site: https://eveneul.github.io/Rivus/
GSAP의 ScrollTrigger는 아래와 같이 사용합니다.
ScrollTrigger.create({
trigger: ".element",
start: "top top",
end: "bottom top",
scrub: 1,
onEnter: () => {...},
onUpdate: () => {...},
// ... 그외 여러 속성
})제가 느낀 ScrollTrigger의 문제점은 스타일이 inline으로 들어간다는 점입니다. 이 때문에 기존에 작성한 CSS와 충돌이 생기기도 했고, 리사이즈가 발생할 때마다 제어하기가 어려웠습니다.
또, 다른 .js(.ts) 파일이나 커스텀 훅으로 분리해도 복잡한 애니메이션일수록 코드가 길어져 유지보수 난이도가 높아진다는 점도 아쉬웠습니다.
CSS로는 애니메이션을 제외한 스타일링만 선언하고, 애니메이션은 기존 GSAP처럼 javascript로 작성하는 방법입니다. 이 방법은 transform: translateY(10px)을 y: '10px'처럼 작성하는 것처럼 명시적으로 애니메이션 속성을 작성할 수 있는 게 장점입니다.
단점은 앞서 말씀 드린 것처럼 코드의 길이가 길어지고, 애니메이션이 복잡해질수록 섹션과 섹션이 이어지는 애니메이션을 구현할 때 어렵습니다.
(예를 들어 A -> B 섹션으로 이동 시 배경이 까맣게 되었다가, B -> C로 이동 시 배경이 하얗게 될 때, onEnter, onEnterBack, onLeave, onLeaveBack 함수를 다 작성해야 하는 문제점)
javascript보다 css로 애니메이션을 주는 것이 성능적으로 더 좋습니다. 무조건적으로 다 좋은 것은 아니지만 브라우저가 layout → paint → composite 순서대로 렌더링을 할 때, transform, opacity 같은 속성은 layout, paint를 건너뛰고 composite 단계만으로 처리할 수 있어서 훨씬 빠릅니다. 즉, 리플로우와 리페인트가 없고, 프레임 드랍이 적습니다. (margin, width/height, border 등 속성은 JS와 똑같이 성능이 무거워집니다.)
CSS 애니메이션을 많이, 그리고 잘 다뤄 본 사람은 top, left보다 transform을, width, height보다는 scale이 더 성능적인 측면에서 좋다는 것은 이미 알기 때문에 애니메이션을 잘 작성한다면 CSS만으로도 성능 저하 없는 애니메이션을 만들 수 있다고 생각합니다.
JS에서는 IntersectionObserver를, HTML에서는 data-* 속성을, CSS에서는 애니메이션을 담당하도록 하는 방식(방법 2)을 채택했습니다.
data-*으로 Rivus를 사용할 수 있게 구성했습니다.
<div data-rivus data-rivus-start="top bottom" data-rivus-end="bottom bottom" data-rivus-enter="false" data-rivus-progress="0"></div>Rivus를 사용하기 위해서는 data-rivus가 필수적입니다.
data-rivus-start와 data-rivus-end는 기존 GSAP의 ScrollTrigger와 동일하게 사용하도록 했습니다. 첫 번째 문자는 element 기준, 두 번째 문자열은 viewport 기준으로, 만약 top bottom이라고 되어 있을 시 요소의 top 부분이 viewport의 bottom 부분에 닿을 때 Rivus가 실행됩니다.
data-rivus-enter는 요소가 start, end 값에 맞게 viewport에 들어왔을 시 true가 됩니다.
data-rivus-progress는 요소가 start, end 값에 맞게 viewport에 들어왔을 시 0에서부터 1까지 progress가 올라가거나 감소됩니다.
먼저, 헬퍼 함수들을 helpers.js에 정의했습니다.
parsePosition
data-*으로 포지션을 top top 같은 문자열 (또는 px, % 단위)이 들어올 때 무엇이 element 기준인지, 또 무엇이 viewport 기준인지 처리해 줍니다.
export const parsePosition = (value) => {
if (!value) return {element: null, viewport: null}
const [element, viewport] = value.split(' ')
return {element, viewport}
}value로 top bottom을 인자로 넣으면, 결과는 {element: "top", viewport: "bottom"}이 리턴됩니다.
parseOptions
export const parseOptions = (element) => {
const options = element.getAttribute('data-rivus-options')
return options ? JSON.parse(options) : {}
}아직은 쓰이지 않지만, 언젠가는 쓰일 options 객체를 자바스크립트에서 객체로 파싱해 줍니다.
data-rivus-options={
"otherOption": true
}이런 식으로 하려고 했으나.. 아직까지는 어디에 쓰일지 좋은 아이디어가 떠올리지 않아서 지우지 않았습니다.
parsePositionValue
export const parsePositionValue = (value, size) => {
if (!value) return 0
// 픽셀 값 (예: "100px")
if (value.endsWith('px')) {
return parseFloat(value)
}
// 퍼센트값 계산
if (value.endsWith('%')) {
const percent = parseFloat(value) / 100
return size * percent
}
// 키워드 값 계산 (top, center, bottom)
switch (value) {
case 'top':
return 0
case 'center':
return size / 2
case 'bottom':
return size
default:
return 0
}
}스타트나 앤드값을 top top 또는 20px 50% 처럼 지정했을 시 Number 타입의 값으로 반환합니다.
인자로는 value와 size값을 받는데, value는 top이나 100px 같은 string 타입으로 된 값을, size는 element 기준 height, viewport는 innerHeight를 받습니다.
getElementPosition,getViewportPosition
// 요소 위치 계산
export const getElementPosition = (boundingRect, position) => {
const height = boundingRect.height
const offset = parsePositionValue(position, height)
return boundingRect.top + offset + window.scrollY
}
// 뷰포트 위치 계산
export const getViewportPosition = (position) => {
const height = window.innerHeight
return parsePositionValue(position, height)
}요소의 위치를 계산하는 함수와 Viewport의 위치를 계산하는 헬퍼 함수입니다.
Rivus는 스크롤 기반 애니메이션을 제어하는 클래스입니다. HTML에서 data-rivus 속성을 가진 요소를 감지하고, 스크롤 위치에 따라 progress를 업데이트합니다. Web API IntersectionObserver를 활용했습니다.
- 생성자 (constructor)
this.el = element
// 파싱된 옵션 저장
this.options = {
start: parsePosition(element.dataset.rivusStart),
end: parsePosition(element.dataset.rivusEnd),
...this.parseOptions()
}
this.startScrollY = 0 // 스크롤이 시작되는 Y (progress 계산)
this.endScrollY = 0 // 스크롤이 끝나는 Y (progress 계산)
this.entered = false // 진입했는지 확인
this.onScroll = this.onScroll.bind(this)
this.computeProgress = this.computeProgress.bind(this)
this.init()동작 과정
- HTML 요소를 받아
this.el에 저장 data-rivus-start와data-rivus-end속성을 파싱하여options객체 생성- 스크롤 계산에 필요한 변수 초기화
init()메서드를 호출하여IntersectionObserver설정
data-rivus-options에 적힌 옵션 파싱
parseOptions() {
const options = this.el.dataset.rivusOptions;
return options ? JSON.parse(options) : {};
}- Progress 계산
computeProgress() {
const rect = this.el.getBoundingClientRect();
const elementStart = getElementPosition(rect, this.options.start.element);
const elementEnd = getElementPosition(rect, this.options.end.element);
const viewportStart = getViewportPosition(this.options.start.viewport);
const viewportEnd = getViewportPosition(this.options.end.viewport);
this.startScrollY = elementStart - viewportStart;
this.endScrollY = elementEnd - viewportEnd;
}동작 과정
- 요소의 height를 알기 위해
getBoundingClientRect()를rect변수에 저장 getElementPosition,getViewportPosition헬퍼 함수를 통해 element와 viewport가 어디에서부터 시작되는지 확인- 스크롤이 시작되어야 하는 시작점(this.startScrollY)을 계산, 마찬가지로 스크롤이 끝나야 하는 시작점(this.endScrollY) 계산
왜 이렇게 계산하나요?
getBoundingClientRect().top은 뷰포트 기준 상대 위치입니다- 실제 스크롤 위치를 계산하려면
window.scrollY를 더해야 합니다 getElementPosition과getViewportPosition이 이를 처리합니다
예시로 이해하기
만약 data-rivus-start="top bottom"이고 data-rivus-end="bottom bottom"인 경우
elementStart: 요소의 top 위치 (페이지 기준 절대 위치)viewportStart: 뷰포트의 bottom 위치 (뷰포트 높이)startScrollY: elementStart - viewportStart = 요소의 top이 뷰포트 bottom에 닿는 스크롤 위치elementEnd: 요소의 bottom 위치viewportEnd: 뷰포트의 bottom 위치endScrollY: elementEnd - viewportEnd = 요소의 bottom이 뷰포트 bottom에 닿는 스크롤 위치
- Scroll 이벤트
onScroll() {
const scrollY = window.scrollY;
// 진입
if (
!this.entered &&
scrollY >= this.startScrollY &&
scrollY <= this.endScrollY
) {
this.entered = true;
this.el.dataset.rivusEnter = "true";
}
// 이탈
if (
this.entered &&
(scrollY < this.startScrollY || scrollY > this.endScrollY)
) {
this.entered = false;
this.el.dataset.rivusEnter = "false";
}
// progress 계산
const progress =
(scrollY - this.startScrollY) / (this.endScrollY - this.startScrollY);
const clamped = Math.min(1, Math.max(0, progress));
this.el.dataset.rivusProgress = clamped;
this.el.style.setProperty("--progress", clamped);
}- 진입 감지
- 요소가 아직 진입하지 않았고 (
!this.entered) - 현재 스크롤이 시작 위치와 끝 사이 위치에 있으면
this.entered를true로 변경하고,data-rivus-enter="true"속성을 추가(변경)합니다.
- 이탈 감지
- 요소가 진입한 상태이고 (
this.entered) - 스크롤 위치를 벗어나면
this.entered를false로 변경,data-rivus-enter="false"속성을 변경합니다.
- Progress 계산 및 업데이트
// progress 계산
const progress = (scrollY - this.startScrollY) / (this.endScrollY - this.startScrollY)
const clamped = Math.min(1, Math.max(0, progress))
this.el.dataset.rivusProgress = clamped
this.el.style.setProperty('--progress', clamped)- 진행도 계산:
(현재 스크롤 - 시작 위치) / (끝 위치 - 시작 위치) clamped: 0과 1 사이로 제한data-rivus-progress속성과 CSS 변수 --progress에 저장
IntersectionObserver사용으로 Element DOM 감지하기
init() {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 진입 시
this.computeProgress(); // progress 계산
this.onScroll();
window.addEventListener("scroll", this.onScroll, { passive: true });
} else {
window.removeEventListener("scroll", this.onScroll);
}
});
});
observer.observe(this.el);
}IntersectionObserver로 DOM 감지this.computedProgress()호출로 현재 레이아웃 상태에 맞춰 스크롤 기준점(startScrollY, endScrollY)를 다시 계산해야 하기 때문- 모든 요소에 스크롤 리스너를 등록하지 않고, 뷰포트에 보이는 요소만 감지하여 성능 최적화, 뷰포트를 벗어난 요소는 이벤트 리스너 제거
passive: true로 스크롤 성능 향상 (브라우저가 스크롤을 더 부드럽게 처리)
먼저, 요소가 viewport 안에 진입 시 data-rivus-enter가 false에서 true로 변하게 되는데,
.sc-kv[data-rivus-enter='true'] h1 span {
transform: translateX(-50%) translateY(0);
}이렇게 감지할 수 있습니다.
rivus Class에서 progress를 css의 변수로 설정한 이유는 @keyframes로 애니메이션을 제어할 수 있기 때문입니다.
@keyframes imageAnimation {
0%,
30% {
width: 400px;
}
90%,
100% {
width: 100vw;
}
}
.sc-image .sticky-area img {
object-fit: cover;
animation: imageAnimation 1s linear forwards paused;
animation-delay: calc(var(--progress) * -1s);
}animation-delay를 calc로 progress * -1s을 하면, Gsap ScrollTrigger의 scrub 기능과 같이 작동됩니다. @keyframes의 퍼센트는 0~1 사이에서 변하는 --progress값을 따르면 됩니다.
