스크롤 트리거 애니메이션(Scroll-Triggered Animations) 첫 살펴보기
요약
Chrome 146에서 새롭게 도입된 스크롤 트리거 애니메이션(Scroll-Triggered Animations)의 개념과 작동 방식을 설명합니다. 기존의 스크롤 구동 애니메이션과 달리 특정 임계값에 도달했을 때 정해진 시간 동안 애니메이션이 재생되는 차이점을 다룹니다.
핵심 포인트
- Chrome 146부터 스크롤 트리거 애니메이션 지원 시작
- 스크롤 구동 애니메이션과 달리 별도의 지속 시간(duration)을 가짐
- timeline-trigger: view()를 사용하여 요소의 진입 시점에 애니메이션 실행
- entry 및 exit 범위를 통해 애니메이션 활성화 구간 설정 가능
Chrome이 스크롤 트리거 애니메이션(scroll-triggered animations)을 출시했으며, 이를 지원하는 최초의 브라우저가 되었습니다. Chrome 146으로 업데이트하면 아래 데모를 확인할 수 있습니다. 이 데모에서는 요소 전체가 뷰포트(viewport) 내에 들어온 후에만 사각형의 배경이 300ms 동안 페이드 인(fade in)됩니다.
CodePen Embed Fallback
이는 스크롤 구동 애니메이션(scroll-driven animations)이 작동하는 방식과는 다소 다르므로, 이 글에서는 두 방식을 비교한 후 스크롤 트리거 애니메이션이 어떻게 작동하는지 보여드리겠습니다.
스크롤 트리거 애니메이션 vs. 스크롤 구동 애니메이션
스크롤 트리거 애니메이션(Scroll-triggered animations)은 특정 스크롤 임계값(threshold)을 넘어서면 정해진 기간 동안 재생됩니다. (JavaScript의 Intersection Observer API가 CSS 애니메이션을 위해 작동한다고 생각하면 됩니다.)
이는 애니메이션 진행 상황이 스크롤 진행 상황(animation-timeline: scroll()) 또는 교차(intersection) 정도(animation-timeline: view())와 동기화되어 따라서 별도의 지속 시간(duration)이 없는 스크롤 구동 애니메이션(scroll-driven animations)과는 다릅니다.
기본적인 스크롤 트리거 애니메이션 예시
핵심 부분은 animation-timeline: view() 대신 timeline-trigger: view()를 사용하는 것입니다. 이는 요소가 임계값 내에 얼마나 들어와 있는지를 측정하여 그에 따라 동작하는 대신, 요소가 임계값 내에 들어올 때까지 기다립니다. 하지만 우선 background를 설정하는 실제 @keyframes 애니메이션부터 시작하겠습니다:
/* 애니메이션 정의 */
@keyframes fade-bg-in {
to {
...
이는 .square에 300ms 동안 설정됩니다:
.square {
/* 애니메이션 선언 */
animation: fade-bg-in 300ms;
...
기본적으로 CSS 애니메이션은 선언이 적용될 때 트리거되지만, 아래의 확장된 코드 스니펫(snippet)에서는 timeline-trigger가 해당 동작을 덮어씁니다. 이제 애니메이션은 요소가 view() 안으로 들어올 때 트리거됩니다. --trigger는 단순히 트리거의 식별자 역할을 하는 점선 식별자(dashed ident)인 반면, entry 100% exit 0%는 타임라인 범위(timeline range)입니다. 타임라인 범위는 애니메이션이 활성화되고 활성 상태를 유지할 수 있는 스크롤 영역(scroll zone)을 지정합니다.
이 경우, .square의 하단 가장자리가 진입할 때(entry 100%) 애니메이션이 트리거되며, 상단 가장자리가 스크롤포트(scrollport)를 벗어날 때(exit 0%) (애니메이션이 여전히 실행 중이라고 가정할 때) 트리거가 해제됩니다. 명확성을 위해 설명하자면, entry 0%는 상단 가장자리가 진입할 때 애니메이션을 트리거할 것입니다. entry는 요소가 스크롤포트의 하단에서 들어오는 것을 처리하고, exit는 요소가 상단을 통해 나가는 것을 처리합니다. 조금 혼란스러울 수 있지만, 너무 과하게 설명하지 않는 것이 이해하기 더 쉬울 것입니다.
.square {
/* 애니메이션 선언 */
animation: fade-bg-in 300ms;
...
CodePen Embed Fallback
play-forwards 키워드는 사각형이 완전히 보일 때마다 애니메이션을 트리거합니다. 그리고 우리는 애니메이션에 대한 채우기 모드(fill mode)를 선언하지 않았기 때문에(animation-fill-mode를 사용하거나 animation 축약형의 일부로 사용하지 않음), 사각형이 애니메이션 이후에 배경을 유지하지 않음을 의미하며, 이로 인해 애니메이션은 일종의 깜빡임처럼 보이게 됩니다.
따라서 다른 결과를 얻기 위해서는 이를 바탕으로 더 발전시켜 나가야 합니다.
animation-fill-mode vs. <animation-action>
먼저, animation-fill-mode 또는 animation 축약형의 일부로서 서로 다른 채우기 모드(fill mode) 값들이 어떤 역할을 하는지 요약해 보겠습니다:
forwards: 애니메이션이 끝난 후에도(after) 스타일이 유지됩니다.backwards: 애니메이션이 시작되기 전에도(before) 스타일이 적용됩니다.both: 두 가지 동작이 모두 적용됩니다.
이제 <animation-action>이 (이전과 같이) play-forwards이고 채우기 모드(fill mode)가 forwards라고 가정해 보겠습니다 (background가 처음부터 설정되어 있지 않으므로 both를 사용하는 것은 중복입니다):
.square {
animation: fade-bg-in 300ms forwards;
timeline-trigger: --trigger view() entry 100% exit 0%;
...
CodePen Embed Fallback
이 설정은 스타일을 유지하게 만들지만, 만약 사각형이 뷰포트(viewport)를 부분적으로 또는 완전히 벗어났다가 다시 진입하면 애니메이션이 재시작됩니다. 이로 인해 애니메이션이 끝나는 방식에 따라 깜빡임(flash) 현상이 발생할 수 있으며, 현재 사례가 바로 그러한 경우입니다.
이를 해결하는 두 가지 방법이 있습니다...
“잠금(lock-in)” 방식: play-forwards 대신 play-once를 사용합니다. 이를 forwards와 결합하면 애니메이션이 단 한 번만 재생되고 다시는 재시작되지 않으며, 그 후 스타일이 유지됩니다.
.square {
/* 한 번만 재생 */
animation-trigger: --trigger play-once;
...
CodePen Embed Fallback
“왕복(back-and-forth)” 방식: play-forwards play-backwards는 요소가 완전히 보일 때는 정상적으로 애니메이션을 재생하고, 더 이상 완전히 보이지 않을 때는 역재생합니다. 요소가 정방향만큼이나 부드럽게 역방향으로 애니메이션되기 때문에 깜빡임이 발생하지 않습니다. 또한, 애니메이션의 방향은 바뀔 수 있지만 채우기 모드는 both로 설정하는 대신 forwards로 유지할 수 있습니다.
왜 그럴까요?
play-forwards는 “애니메이션을 0%에서 100%까지 재생하라”는 의미인 반면, play-backwards는 “애니메이션을 100%에서 0%까지 재생하라”는 의미입니다. 한편, 앞서 언급했듯이 forwards 채우기 모드는 “애니메이션이 완료되었을 때 스타일을 유지하라”는 의미입니다. 즉, 마지막 키프레임(keyframe)이 0%이든 100%이든 상관없이 적용됩니다.
.square {
/* 상황에 따라 정방향 및 역방향으로 재생 */
animation-trigger: --trigger play-forwards play-backwards;
...
CodePen Embed Fallback
play-forwards, play-once, 그리고 play-backwards가 <animation-action>을 위한 유일한 키워드는 아닙니다. 다음은 간단한 요약입니다:
<animation-action> | 효과 |
|---|---|
none | 조건부로 트리거를 비활성화하거나, 진입 시에는 작동하지만 퇴장 시에는 작동하지 않게 하거나(또는 그 반대), 하나의 animation-trigger로 여러 트리거를 처리할 때 사용 |
| ... | |
이러한 <animation-action>들은 스크롤하는 동안 애니메이션에 대해 상당한 수준의 제어를 가능하게 할 뿐만 아니라, 액션(actions), 채우기 모드(fill modes), 타임라인 범위(timeline ranges)의 다양한 조합, 그리고 퇴장 애니메이션(exit animations)을 @keyframes 규칙에 포함할 수 있다는 사실 덕분에 원하는 결과를 얻기 위한 방법이 여러 가지인 경우가 많습니다. |
여러 요소의 스크롤 트리거링 (Scroll-triggering multiple elements)
스크롤 트리거 애니메이션이 애니메이션 액션, 채우기 모드, 타임라인 범위 및 그 이상의 요소로 구성되어 있어 복잡해 보일 수 있지만, 이러한 메커니즘들이 서로 분리(decoupled)되어 있다는 사실 덕분에 유연성을 유지하면서 로직을 재사용할 수 있으며, 반복을 줄이고 메커니즘을 디자인 시스템(design system) 친화적으로 만들 수 있습니다.
이번에는 세 개의 사각형을 고려해 보겠습니다. 약간의 복잡성을 더하기 위해 scale: 70%(initial로 애니메이션됨)를 선언하고 두 개의 회전 애니메이션을 정의합니다.
<div id="squares">
<div class="square rotate-left"></div>
<div class="square"></div>
...
/* 애니메이션 정의 */
@keyframes intensify {
to {
...
그 이후 과정은 이전과 유사합니다. 분명 더 복잡한 예시이긴 하지만, 값들을 축약 속성(shorthand properties)으로 병합하거나 이를 개별 속성(longhand properties)으로 분리할 수 있다는 점, 그리고 서로 다른 메커니즘의 분리된 특성 덕분에 유연성과 재사용성(이 경우에는 동일한 애니메이션 트리거 설정을 사용하여 다양한 애니메이션을 순차적으로 실행(stagger)하는 것)이 용이해집니다:
.square {
/* 시작 값 설정 */
scale: 70%;
...
CodePen Embed Fallback
애니메이션을 순차적으로 실행(stagger)하기 위해 sibling-count()와 sibling-index()(Firefox 지원 미비)를 사용하는 더 깔끔하고 견고한 버전은 다음과 같습니다:
CodePen Embed Fallback
이 버전에서는 각 개별 사각형(.square)에 timeline-trigger-activation-range-start를 설정하는 대신, 단순히 .square를 대상으로 지정하고 진입 값(entry values)을 즉석에서 계산합니다:
/* 최대 진입 값 ÷ 사각형 개수 */
--stagger-interval: calc(100% / sibling-count());
...
하나의 요소가 다른 요소들을 트리거하게 만들기
이 경우에는 트리거와 그 범위를 첫 번째 사각형으로 옮기고, 나머지 사각형들이 순차적인 애니메이션 지연(staggered animation delay)에 따라 따라오도록 만들 것입니다. 보시다시피, 모든 애니메이션은 첫 번째 사각형의 50%가 뷰포트(view())에 진입(entry 50%)하면 animation-trigger에 의해 트리거됩니다. 점선 식별자(적절하게 명명된 --trigger)가 이들을 연결하기 때문에 animation-trigger는 timeline-trigger에 의해 트리거됩니다:
/* 애니메이션 정의 */
@keyframes intensify {
to {
...
CodePen Embed Fallback
한 가지 단점은 animation-trigger가 play-backwards 모드일 때 애니메이션이 순차적으로 실행되지 않는다는 점입니다. 제 생각에는 애니메이션이 역재생될 때 지연(delay) 값이 그 안에 포함되기 때문인 것 같습니다. animation-direction: reverse를 사용할 때는 그렇지 않다는 점을 고려하면, 이는 실수(oversight)처럼 보이지만 제가 완전히 틀렸을 수도 있습니다.
타임라인 범위(timeline ranges) 이해하기
타임라인 범위(Timeline ranges)는 스크롤 트리거 애니메이션(scroll-triggered animations)에서 큰 비중을 차지하지만, 별개의 메커니즘입니다. 스크롤 기반(scroll-driven) 애니메이션의 경우 animation-range와 그 개별 속성(longhand properties)들을 사용하게 됩니다. 스크롤 트리거(scroll-triggered) 애니메이션의 경우, 구문(syntax)은 근본적으로 동일하지만 다른 속성들과 두 가지의 서로 다른 범위를 사용합니다. 활성화 범위(activation range)는 애니메이션이 트리거되는 스크롤 영역을 결정하며, 활성 범위(active range)는 애니메이션이 유지되는 영역을 결정합니다 (설령 더 이상 활성화 범위에 있지 않더라도 말이죠).
타임라인 범위는 다소 복잡합니다. 하지만 대부분의 경우 view() entry 100% exit 0% (완전히 보일 때)와 view() contain (동일하지만 뷰포트(viewport)보다 큰 경우도 포함)만으로도 충분할 것입니다.
하지만 더 깊이 파고들고 싶다면, animation-range를 살펴보는 것을 추천합니다. 비록 이것은 스크롤 기반(scroll-driven) 애니메이션을 위한 것이지만, 더 가볍고 타임라인 범위에 대한 초보자 수준의 이해를 제공합니다. 그 이후에는 Animation Triggers 명세(spec)를 읽어보며, 이러한 스크롤 트리거 애니메이션의 맥락 내에서 타임라인 범위가 가진 수많은 복잡한 세부 사항들을 다루어 보시길 권장합니다.
스크롤 트리거 애니메이션의 또 다른 구성 요소이자 그 자체로 독립적인 기능은 view() 함수이지만, 이는 여기서 요약하기가 더 쉽습니다. 기본적으로 스크롤 트리거 애니메이션에 있어서 view()는 뷰포트(viewport)를 의미합니다. 따라서 만약 5rem 크기의 스티키 헤더(sticky header)가 있다면, view(y 0 5rem)은 타임라인 범위가 y축을 따라 이를 고려하도록 만듭니다.
마치며
스크롤 트리거 애니메이션은 스크롤 기반(scroll-driven) 애니메이션과 유사하고, 기존의 CSS 기능(주로 animation)뿐만 아니라 다른 최신 기능들의 메커니즘(대시 식별자(dashed idents), view(), 타임라인 범위)과 스크롤 트리거 애니메이션에 특화된 CSS 속성들을 모두 활용하기 때문에 까다로울 수 있습니다. 한꺼번에 정말 많은 일들이 일어나고 있는 셈입니다.
솔직히 말해서, 이에 대해 어떻게 느껴야 할지 잘 모르겠습니다. 확실히 멋지고 재미있으며 유용하지만, 동시에 복잡하기도 합니다. 제가 이것들에 대해 진심으로 열광하기까지는 시간이 좀 걸릴 것 같습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 CSS-Tricks의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기