
라틴어로 작은 강을 뜻하는 Rivus. 애니메이션 라이브러리 GSAP의 ScrollTrigger를 Vanilla Javascript로 구현했습니다.
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에 정의했습니다.
parsePositiondata-*으로 포지션을 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"}이 리턴됩니다.
parseOptionsexport const parseOptions = (element) => {
const options = element.getAttribute('data-rivus-options')
return options ? JSON.parse(options) : {}
}
아직은 쓰이지 않지만, 언젠가는 쓰일 options 객체를 자바스크립트에서 객체로 파싱해 줍니다.
data-rivus-options={
"otherOption": true
}
이런 식으로 하려고 했으나.. 아직까지는 어디에 쓰일지 좋은 아이디어가 떠올리지 않아서 지우지 않았습니다.
parsePositionValueexport 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를 활용했습니다.
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()
동작 과정
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) : {};
}
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;
}
동작 과정
getBoundingClientRect()를 rect 변수에 저장getElementPosition, getViewportPosition 헬퍼 함수를 통해 element와 viewport가 어디에서부터 시작되는지 확인왜 이렇게 계산하나요?
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에 닿는 스크롤 위치 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 계산
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값을 따르면 됩니다.
사실 이 방법은 사내 실장님께서 만든 라이브러리 중 CSS 부분을 참고해서 만들었습니다. 무엇보다 CSS keyframes와 animation-delay로 GSAP의 scrub 효과를 만들 수 있다는 점이 매력적이었습니다. 복잡한 GSAP 대신 쉽게 사용할 수 있도록 만들어서, 나중에 사이드 프로젝트나 개인 포트폴리오, 외주 사이트에 적용해 볼 예정입니다!
typescript로 마이그레이션하기와 React.js에서도 쉽게 사용할 수 있도록 Custom Hook이나 Component로 만드는 것도 생각 중입니다. 지금은 기말고사가 있어서.. 나중에 기회가 되면 해 보는 걸로!
김실장 : 사실은 다 css로 할 수 있습니다.