CSS 스크롤 기반 애니메이션: JavaScript 없이 스크롤 인터랙션 구현

프론트엔드

CSS스크롤 애니메이션animation-timeline인터랙션성능 최적화

이 글은 누구를 위한 것인가

  • JavaScript IntersectionObserver 없이 스크롤 기반 애니메이션을 구현하려는 팀
  • 스크롤 진행률 바, 패럴랙스, 요소 등장 효과를 CSS만으로 만들려는 개발자
  • 스크롤 이벤트 리스너로 인한 성능 문제를 CSS로 해결하려는 팀

들어가며

기존 스크롤 애니메이션은 JavaScript 이벤트 리스너와 requestAnimationFrame이 필요했다. CSS Scroll-Driven Animations는 animation-timeline 속성 하나로 스크롤 위치와 CSS 애니메이션을 연결한다. 메인 스레드가 아닌 컴포지터에서 실행되어 성능도 우수하다.

이 글은 bluefoxdev.kr의 CSS 스크롤 애니메이션 가이드 를 참고하여 작성했습니다.


1. Scroll-Driven Animations 개요

[두 가지 타임라인]

1. scroll() - 스크롤 컨테이너의 스크롤 위치
   scroll(scroller, axis)
   scroller: nearest | root | self
   axis: y | x | block | inline
   
   animation-timeline: scroll()         → 가장 가까운 스크롤 컨테이너, 세로
   animation-timeline: scroll(root)     → 페이지 전체 스크롤
   animation-timeline: scroll(self x)  → 자기 자신 가로 스크롤

2. view() - 뷰포트 내 요소의 가시 영역 비율
   view(axis, inset)
   
   animation-timeline: view()          → 요소가 뷰포트에 들어오는 비율
   
   범위 제어:
   animation-range: cover             → 요소가 뷰포트에 처음 나타날 때 ~ 완전히 벗어날 때
   animation-range: entry             → 뷰포트에 들어오는 구간만
   animation-range: exit              → 뷰포트에서 나가는 구간만
   animation-range: entry 20% exit 80%  → 구체적 범위

[브라우저 지원]
  Chrome 115+, Edge 115+
  Firefox: 플래그 필요 (2024 기준)
  Safari: 일부 지원

[@supports 폴백]
  @supports (animation-timeline: scroll()) { ... }
  → 미지원 브라우저는 기본 스타일 유지

2. Scroll-Driven 애니메이션 구현

/* 1. 스크롤 진행률 바 (가장 간단한 예시) */
@keyframes grow-progress {
  from { width: 0%; }
  to   { width: 100%; }
}

.progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  height: 4px;
  background: linear-gradient(to right, #6366f1, #8b5cf6);
  
  animation: grow-progress linear;
  animation-timeline: scroll(root);  /* 페이지 스크롤에 연동 */
}
/* 2. 요소 등장 애니메이션 (view() 타임라인) */
@keyframes fade-slide-in {
  from {
    opacity: 0;
    transform: translateY(40px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.reveal {
  animation: fade-slide-in linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 40%;  /* 뷰포트에 40% 들어올 때까지 */
}

/* 3. 카드 순차 등장 (delay 없이 stagger 효과) */
.card-list .card {
  animation: fade-slide-in linear both;
  animation-timeline: view();
  animation-range: entry 10% cover 30%;
}

.card:nth-child(1) { animation-range-start: entry 5%;  }
.card:nth-child(2) { animation-range-start: entry 15%; }
.card:nth-child(3) { animation-range-start: entry 25%; }
/* 4. 패럴랙스 효과 */
@keyframes parallax-slow {
  from { transform: translateY(0); }
  to   { transform: translateY(-120px); }
}

@keyframes parallax-fast {
  from { transform: translateY(0); }
  to   { transform: translateY(-200px); }
}

.hero-background {
  animation: parallax-slow linear;
  animation-timeline: scroll(root);
}

.hero-foreground {
  animation: parallax-fast linear;
  animation-timeline: scroll(root);
}

/* 5. 수평 스크롤 갤러리 */
.horizontal-gallery {
  overflow-x: scroll;
  display: flex;
  gap: 16px;
  scroll-snap-type: x mandatory;
}

.gallery-item {
  scroll-snap-align: start;
  flex-shrink: 0;
  width: 80vw;
  
  animation: scale-in linear both;
  animation-timeline: view(x);  /* 가로 스크롤 */
  animation-range: entry 0% cover 40%;
}

@keyframes scale-in {
  from { transform: scale(0.8); opacity: 0.5; }
  to   { transform: scale(1);   opacity: 1;   }
}
/* 6. 스크롤 연동 헤더 변환 */
@keyframes header-shrink {
  from {
    padding: 24px 32px;
    background: transparent;
    box-shadow: none;
  }
  to {
    padding: 12px 32px;
    background: rgba(255, 255, 255, 0.95);
    box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1);
    backdrop-filter: blur(8px);
  }
}

header {
  position: sticky;
  top: 0;
  z-index: 100;
  
  animation: header-shrink linear forwards;
  animation-timeline: scroll(root);
  animation-range: 0px 200px;  /* 처음 200px 스크롤 동안 */
}

/* 7. 텍스트 마스크 리빌 */
@keyframes text-reveal {
  from { clip-path: inset(0 100% 0 0); }
  to   { clip-path: inset(0 0% 0 0); }
}

.animated-headline {
  animation: text-reveal cubic-bezier(0.4, 0, 0.2, 1) both;
  animation-timeline: view();
  animation-range: entry 0% entry 60%;
}
// JavaScript ScrollTimeline API (프로그래매틱 제어)
const target = document.querySelector('.animated-element');

const scroller = document.querySelector('.scroll-container');

// ScrollTimeline
const scrollTimeline = new ScrollTimeline({
  source: scroller,
  axis: 'block',
});

target.animate(
  [
    { opacity: 0, transform: 'translateY(50px)' },
    { opacity: 1, transform: 'translateY(0)' },
  ],
  {
    duration: 1,  // 타임라인 기반이므로 duration은 1
    fill: 'both',
    timeline: scrollTimeline,
  }
);

// ViewTimeline
const viewTimeline = new ViewTimeline({
  subject: target,
  axis: 'block',
});

target.animate(
  [{ opacity: 0 }, { opacity: 1 }],
  {
    fill: 'both',
    timeline: viewTimeline,
    rangeStart: 'entry 0%',
    rangeEnd: 'entry 50%',
  }
);

// 폴백 감지
if ('ScrollTimeline' in window) {
  // CSS Scroll-Driven Animations 지원
} else {
  // IntersectionObserver 폴백
  const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach(entry => {
        entry.target.classList.toggle('visible', entry.isIntersecting);
      });
    },
    { threshold: [0, 0.5, 1] }
  );
  
  document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
}

마무리

CSS Scroll-Driven Animations의 핵심은 animation-timeline: scroll()animation-timeline: view()다. scroll()은 스크롤 위치에 따라, view()는 요소의 뷰포트 가시 비율에 따라 애니메이션이 진행된다. animation-range로 전환 구간을 세밀하게 제어하고, CSS 자체가 컴포지터에서 실행되어 requestAnimationFrame보다 성능이 우수하다. Chrome 115 미만은 JavaScript ScrollTimeline API로 동일한 효과를 구현할 수 있다.