[CSS] JavaScript 없이 스크롤 따라 떠오르는 애니메이션 만들기 (animation-timeline: view())

옹잉·2026년 9월 30일

✨ 알게된 것

목록 보기
10/10

1. 배경

포트폴리오 사이트를 만들면서 스크롤을 내리면 섹션이 아래에서 스르륵 떠오르는 애니메이션을 넣고 싶었다.

JavaScript 없이 CSS만으로 구현해보고 싶어서 animation-timeline: view()를 사용했다.
CSS로 구현하면 스크롤 감지와 리렌더링 없이 브라우저가 메인 스레드와 분리해 애니메이션을 처리할 수 있어 더 가볍고 부드럽다.

2. 구현 코드

Tailwind CSS v4의 @utility로 만들어서 섹션 공통 컴포넌트에 클래스만 붙였다.

  • globals.css
@keyframes fade-up {
  from {
    opacity: 0;
    translate: 0 15rem;
  }
  to {
    opacity: 1;
    translate: 0 0;
  }
}

@utility animate-fade-up-on-scroll {
  @supports (animation-timeline: view()) {
    @media (prefers-reduced-motion: no-preference) {
      animation-name: fade-up;
      animation-timing-function: linear;
      animation-fill-mode: both;
      animation-timeline: view();
      animation-range: entry 0% cover 50%;
    }
  }
}
  • 섹션 컴포넌트에 적용
<section className="animate-fade-up-on-scroll">...</section>

3. 속성 정리

animation-timeline: view()

일반 애니메이션은 시간을 따라 진행되지만, view()를 주면 요소가 화면에 보이는 정도를 따라 진행된다.
스크롤을 멈추면 애니메이션도 멈추고, 거꾸로 스크롤하면 거꾸로 진행된다.

덕분에 화면 아래로 나가면 다시 내려가고, 위로 지나간 섹션은 보이는 상태로 남는 동작이 로직 없이 자동으로 된다.

animation-range

전체 구간 중 어디서 애니메이션을 진행할지 정한다.

  • entry: 요소가 화면 아래에서 들어오는 구간
  • cover: 요소가 화면에 들어올 때부터 완전히 나갈 때까지 전체 구간

entry 0% cover 50%는 요소가 들어오기 시작할 때부터 전체 구간의 50% 지점까지 애니메이션을 진행한다는 뜻이다.

animation-fill-mode: both

애니메이션 구간 밖에서의 상태를 정한다.

값구간 전구간 후
none원래 스타일원래 스타일
backwardsfrom 상태원래 스타일
forwards원래 스타일to 상태
bothfrom 상태to 상태

처음엔 네 값의 차이가 안 느껴졌는데, 이유는 두 가지였다.

  • to(opacity: 1, 제자리)가 원래 스타일과 같아서 구간 후에는 차이가 없음
  • 구간이 entry 0%부터 시작해서, 구간 전에는 요소가 화면 밖이라 안 보임

animation-range: entry 50% cover 50%처럼 시작점을 늦추면 차이가 바로 보인다.
none은 이미 보이던 요소가 50% 지점에서 갑자기 투명해졌다가 다시 떠오르며 깜빡인다.
나중에 값을 바꿔도 튀지 않게 both를 쓰는 게 안전하다.

animation-timing-function: linear

이미 스크롤 속도를 따라가기 때문에, 가속·감속 곡선을 넣으면 스크롤과 움직임이 어긋나게 느껴진다.

4. 삽질: 애니메이션이 적용되지 않음

처음에는 animation 줄임 속성으로 작성했다.

animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;

그런데 아무 효과가 없었다.

1차로 브라우저 지원과 동작 줄이기 설정을 확인했다.

CSS.supports("animation-timeline: view()"); // true
window.matchMedia("(prefers-reduced-motion: reduce)").matches; // false

둘 다 문제없었는데, 개발자 도구 Styles 탭을 보니 animation-timeline: view()가 아예 없었다.

5. 원인과 해결

animation은 여러 속성을 한 번에 정하는 줄임 속성이라, animation-timeline보다 뒤에 오면 animation-timeline을 기본값으로 덮어쓴다.
코드에서는 순서를 지켰지만, 빌드 과정에서 CSS가 정리되면서 이 순서가 영향을 받은 것으로 보인다. (Tailwind v4와 Next.js는 Lightning CSS로 CSS를 처리한다)

줄임 속성 대신 개별 속성으로 풀어 쓰니 바로 해결됐다.

animation-name: fade-up;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: view();

6. scroll-driven animation을 쓸 때 챙겨야 할 것

꼭 같이 써야 하는 세트

하나라도 빠지면 동작하지 않거나, 특정 사용자에게 문제가 생기는 속성들이다.

@keyframes fade-up { ... }                /* ① 움직임 정의 */

@utility animate-fade-up-on-scroll {
  @supports (animation-timeline: view()) {         /* ④ 지원 브라우저에서만 */
    @media (prefers-reduced-motion: no-preference) { /* ⑤ 동작 줄이기 존중 */
      animation-name: fade-up;              /* ① keyframes와 연결 */
      animation-fill-mode: both;            /* ③ 구간 전후 상태 유지 */
      animation-timeline: view();           /* ② 스크롤에 연결 */
    }
  }
}

① @keyframes + animation-name
무엇을 어떻게 움직일지 정의하고, 이름으로 연결한다. 이름이 다르면 아무 일도 일어나지 않는다.

② animation-timeline: view()
애니메이션을 시간이 아니라 스크롤에 연결하는 핵심 속성이다.
animation 줄임 속성과 함께 쓰면 덮어써질 수 있으니 개별 속성으로 쓴다. (4번 삽질의 원인)

③ animation-fill-mode: both
이게 없으면 구간이 끝나는 순간 원래 스타일로 튀어 돌아가거나, 구간 전에 요소가 그대로 보였다가 깜빡일 수 있다.

④ @supports (animation-timeline: view())
아직 모든 브라우저가 지원하지 않는다.
지원하지 않는 브라우저는 animation-timeline을 무시하는데, 이때 애니메이션이 어떻게 보일지는 다른 animation 속성 설정에 따라 달라질 수 있다.
지원할 때만 적용되도록 감싸두면, 지원하지 않는 브라우저에서는 애니메이션 없이 콘텐츠가 그대로 보인다는 게 확실해진다.
기본 화면은 누구나 보고, 지원하는 브라우저에서만 효과를 더하는 점진적 향상(progressive enhancement) 방식이다.

⑤ prefers-reduced-motion
운영체제에서 동작 줄이기를 켠 사용자에게는 애니메이션을 적용하지 않는다.
움직이는 효과를 넣을 때 항상 함께 챙겨야 하는 접근성 항목이다.

같이 챙기면 좋은 것

  • animation-range: 없으면 요소가 화면에 들어와서 완전히 나갈 때까지 전체 구간에 걸쳐 진행돼서, 화면 위쪽에 가서야 다 떠오른다. 원하는 구간을 정해주는 게 좋다.
  • animation-timing-function: linear: 스크롤 속도와 움직임이 어긋나지 않게 한다.
  • opacity, translate만 움직이기: width, margin 같은 속성은 매번 레이아웃을 다시 계산해서 버벅거린다.
  • 첫 화면(LCP 요소)에는 쓰지 않기: 처음부터 투명하게 시작하면 LCP가 늦어진다.
  • fixed 요소에 z-index 주기: 애니메이션이 적용된 요소는 translate, opacity 때문에 fixed 요소와 같은 층에서 그려진다. 이때 사이드바(fixed)가 섹션 컴포넌트에 덮여 클릭되지 않았다. 사이드바에 z-index를 줘서 해결했다.

참고

profile
틀리더라도 🌸🌈🌷예쁘게 지적해주세요💕❣️

0개의 댓글