들어가며: 스크롤을 “시간"으로 다루는 감각

스크롤 기반 애니메이션을 만들다 보면 이런 순간이 있습니다.

  • “스크롤 위치를 그냥 이벤트로 처리하지 말고”
  • “이걸 타임라인처럼 다루면 훨씬 깔끔할 텐데?”

Scroll-Driven Animation은 바로 그 감각을 공식화한 접근입니다. 스크롤을 단순한 입력이 아니라 애니메이션의 시간 축(timeline)으로 바라보는 방식이죠.

이번 글에서는 구현 방식을 3가지로 나눠서 소개합니다.

  1. 네이티브 CSS Scroll-Driven Animation
  2. IntersectionObserver 기반 토글 방식
  3. scroll + requestAnimationFrame으로 진행률을 직접 매핑하는 방식

그리고 각 방식의 원리, 장단점, 접근성 이슈와 대응까지 함께 정리합니다. 글 안의 코드만 봐도 동작을 이해할 수 있게 작성하였고, 직접 만져보는 데모는 각 절 끝에 링크로 붙여두었습니다.

어두운 배경에서 두 손으로 스마트폰을 잡고 한 손가락으로 화면을 넘기는 모습 - 하루에도 수백 번 하는 이 동작이 애니메이션의 시간 축이 됩니다
어두운 배경에서 두 손으로 스마트폰을 잡고 한 손가락으로 화면을 넘기는 모습 - 하루에도 수백 번 하는 이 동작이 애니메이션의 시간 축이 됩니다
사진: UnsplashGilles Lambert

핵심 원리: 스크롤 진행률을 0~1로 보는 시선

세 가지 접근은 디테일이 다르지만, 핵심은 같습니다.

  • 어떤 기준(페이지, 특정 컨테이너, 특정 요소의 가시 영역)에 대해
  • 현재 스크롤이 어디까지 왔는지를
  • 0에서 1 사이의 진행률(progress)로 해석하고
  • 그 값을 애니메이션에 연결한다

차이는 “이 연결을 누가 하느냐"입니다.

  • CSS가 하게 할 것인가
  • 브라우저 관찰자(IntersectionObserver)에게 맡길 것인가
  • 우리가 JS로 직접 매핑할 것인가
스크롤을 진행률로 읽는 흐름 다이어그램 - 왼쪽 기준 영역(페이지·컨테이너·요소의 가시 범위)에서 지금 보이는 곳을 잡아, 가운데에서 0에서 1 사이 진행률(그림에서는 0.62)로 읽고, 오른쪽에서 opacity·transform·width 같은 속성에 연결합니다. 아래에는 이 연결을 누가 하느냐가 세 갈래로 갈린다는 정리가 있습니다 - CSS가 하는 animation-timeline, 관찰자가 하는 IntersectionObserver, 우리가 직접 하는 scroll과 rAF 매핑
스크롤을 진행률로 읽는 흐름 다이어그램 - 왼쪽 기준 영역(페이지·컨테이너·요소의 가시 범위)에서 지금 보이는 곳을 잡아, 가운데에서 0에서 1 사이 진행률(그림에서는 0.62)로 읽고, 오른쪽에서 opacity·transform·width 같은 속성에 연결합니다. 아래에는 이 연결을 누가 하느냐가 세 갈래로 갈린다는 정리가 있습니다 - CSS가 하는 animation-timeline, 관찰자가 하는 IntersectionObserver, 우리가 직접 하는 scroll과 rAF 매핑
스크롤을 시간처럼 바라보는 핵심 원리

이제 하나씩 보겠습니다.


1) 네이티브 CSS Scroll-Driven Animation

어떻게 동작하나요?

평소 CSS 애니메이션은 시간을 따라 흐릅니다. animation-duration: 2s라고 쓰면 2초 동안 진행되죠. 여기서 그 시간 축을 스크롤 축으로 갈아 끼우는 게 전부입니다.

시간 축을 갈아 끼우는 속성이 animation-timeline입니다. 넣을 수 있는 값은 크게 두 갈래예요.

이름 없이 바로 쓰기 (익명 타임라인)

  • scroll() — 스크롤 컨테이너의 스크롤 진행률을 시간으로 씁니다. 괄호 안에 기준 스크롤러(nearest·root·self)와 축(block·inline·x·y)을 적을 수 있어요. 예: scroll(root block)
  • view()그 요소가 화면에 들어오고 나가는 동안을 0에서 1로 씁니다. 등장 연출에 어울립니다.

이름 붙여 쓰기 (명명 타임라인)

  • 기준이 될 요소에 scroll-timeline: --이름 또는 view-timeline: --이름을 선언해두고
  • 애니메이션할 요소에서 animation-timeline: --이름으로 불러옵니다
  • 이때 이름은 반드시 --로 시작해야 합니다(CSS의 <dashed-ident> 규칙). -- 없이 page-scroll이라고 쓰면 값 자체가 무효라 조용히 무시됩니다

예전 초안에는 @scroll-timeline이라는 at-rule도 있었는데, 명세가 다시 쓰이면서 사라졌습니다. 지금 문법은 위처럼 전부 속성이에요. 오래된 블로그 글을 보고 @scroll-timeline을 그대로 옮겨 적으면 동작하지 않습니다.

가장 작은 예시: 진행 바(progress bar)

아래 예시는 문서 스크롤에 맞춰 상단 바가 좌우로 채워지는 패턴입니다.

html
<div class="sda-progress" aria-hidden="true"></div>
css
.sda-progress {
  position: fixed;
  inset: 0 0 auto 0;
  height: 4px;
  background: linear-gradient(90deg, #0ea5e9, #22c55e);
  transform-origin: 0 50%;
  transform: scaleX(0);
  animation: sda-fill linear both;
  /* 진행은 스크롤이 정하므로 길이 값 자체는 의미가 없습니다.
     다만 0이면 안 되므로 관례적으로 1ms를 적습니다. */
  animation-duration: 1ms;
  animation-timeline: scroll(root block);
}

@keyframes sda-fill {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

이 코드는 “페이지 스크롤 진행률"을 그대로 scaleX에 연결합니다. JS 없이도 스크롤 진행과 애니메이션 진행이 동기화됩니다.

같은 걸 명명 타임라인으로 쓰면 이렇게 됩니다. 스크롤 컨테이너가 문서 루트가 아니라 특정 박스일 때 이 방식이 필요해요.

css
.article-scroller {
  overflow-y: auto;
  scroll-timeline: --article-scroll block;
}

.sda-progress {
  /* ...위와 동일... */
  animation-timeline: --article-scroll;
}

장점

  • 스크롤-애니메이션 동기화가 선언적으로 표현됩니다
  • JS가 거의 필요 없습니다
  • 애니메이션 의도가 코드에 잘 드러납니다
  • transform·opacity처럼 합성(compositing) 가능한 속성만 건드리면, 스크롤 핸들러 없이도 메인 스레드 밖에서 부드럽게 돌 여지가 생깁니다. 크롬 팀도 이 점을 네이티브 방식의 이점으로 설명합니다

단점 / 주의점

  • 브라우저 지원 범위를 반드시 확인해야 합니다
  • 아직은 팀 내에서 낯선 문법일 수 있습니다
  • 폴백(fallback) 설계가 필요할 수 있습니다
  • 한 번 나타난 것을 그대로 붙잡아둘 수 없습니다

마지막 항목은 접근성과 직결되니 조금 더 풀어둘게요. 스크롤 진행률에 묶인 애니메이션은 위치에 대응합니다. 진행률 0.6에서 만들어진 상태는 다시 0.6으로 돌아오면 똑같이 재현되고, 0으로 올라가면 처음 상태로 되감깁니다.

animation-fill-mode: forwards로 붙잡을 수 있을 것 같지만 안 됩니다. fill-mode는 타임라인 구간 밖의 상태를 정할 뿐, 구간 안에서의 되감기를 막지는 못하거든요.

그래서 “한 번 읽은 내용은 다시 사라지지 않게 하고 싶다"면 — 되돌아 읽는 사람에게 중요한 배려입니다 — 네이티브 CSS만으로는 답이 안 나옵니다. 뒤에 나오는 IntersectionObserver나 rAF 쪽에서 한 번 켠 상태를 꺼주지 않는 방식으로 처리해야 합니다.

브라우저 지원 범위: “가능/불가능"보다 “전략"으로 보기

네이티브 scroll-driven은 플래그 뒤에 숨어 있던 시기를 지났지만, 아직 모든 브라우저에서 쓸 수 있는 기능은 아닙니다. 2026년 8월 기준으로 정리하면 이렇습니다.

데이터 표
브라우저animation-timeline·scroll()·view() 지원
Chrome · Edge115부터 지원 (2023년 7월)
Safari (macOS·iOS)26부터 지원 (2025년 9월)
Firefox정식 릴리스 미지원. Nightly에서만 기본 켜져 있고, 다른 채널에서는 about:configlayout.css.scroll-driven-animations.enabled를 직접 켜야 합니다

여기서 Baseline이라는 말이 나오면 같이 봐두면 좋습니다. 웹 기능이 주요 브라우저에서 얼마나 두루 쓸 수 있는지를 세 등급(제한적 지원 / 새로 사용 가능 / 널리 사용 가능)으로 표시하는 지표예요. Scroll-Driven Animation은 파이어폭스가 빠져 있어서 아직 제한적 지원(Limited availability)입니다.

즉, “이제 다 되니까 그냥 쓰자"는 아직 이릅니다. 그래서 실무에서는 보통 이렇게 접근하는 편이 안전합니다.

  1. animation-timeline/view-timeline을 1차 시도로 두고
  2. 안 되는 브라우저에서는 “망가지지 않는 폴백"으로 흘려보내기

아래처럼 @supports로 가드를 걸어두면 폴백 경로를 분명히 할 수 있습니다.

css
.progress-fallback {
  transform: scaleX(1);
}

@supports (animation-timeline: scroll()) {
  .progress-fallback {
    transform: scaleX(0);
    animation: sda-fill linear both;
    animation-duration: 1ms;
    animation-timeline: scroll(root block);
  }
}

또 하나, 처음 쓰면 거의 한 번씩 걸리는 함정이 있습니다.

animation 단축 속성은 animation-timeline을 초기값 auto로 되돌립니다. 값을 안 적었으니 그대로 두는 게 아니라, 아예 리셋해버려요. 그래서 순서를 뒤집어 쓰면 타임라인이 통째로 날아갑니다.

css
/* 잘못된 순서 — 타임라인이 지워집니다 */
.bar {
  animation-timeline: scroll(root block);
  animation: sda-fill linear both;
}

/* 올바른 순서 — 단축 속성을 먼저, 타임라인을 나중에 */
.bar {
  animation: sda-fill linear both;
  animation-timeline: scroll(root block);
}

그리고 animation-duration은 스크롤 타임라인에서 진행 속도를 결정하지 않지만, 값이 0이면 브라우저에 따라 애니메이션이 아예 시작되지 않습니다. 관례적으로 1ms를 적어두는 이유예요.

지원 범위는 한 곳만 보지 말고 caniuse·MDN·Web Platform Status를 같이 보는 편이 안전합니다. 미리보기 채널이 섞여 들어간 표를 초록색만 보고 넘기면 배포 뒤에 어긋나거든요.

2026년 8월 기준 CSS Scroll-Driven Animation 지원 현황 표 - Baseline 등급은 제한적 지원이고, 크롬과 엣지는 115버전부터, 사파리는 26버전부터 지원하지만 파이어폭스는 정식 릴리스에서 아직 미지원이라 Nightly에서 플래그를 켜야 동작합니다. 일부 지원표가 파이어폭스를 초록으로 칠하는 건 Nightly 열이 미리보기로 채워진 것이라는 주의와, 되나 안 되나가 아니라 안 될 때 무엇이 보이나로 접근하라는 결론이 함께 있습니다
2026년 8월 기준 CSS Scroll-Driven Animation 지원 현황 표 - Baseline 등급은 제한적 지원이고, 크롬과 엣지는 115버전부터, 사파리는 26버전부터 지원하지만 파이어폭스는 정식 릴리스에서 아직 미지원이라 Nightly에서 플래그를 켜야 동작합니다. 일부 지원표가 파이어폭스를 초록으로 칠하는 건 Nightly 열이 미리보기로 채워진 것이라는 주의와, 되나 안 되나가 아니라 안 될 때 무엇이 보이나로 접근하라는 결론이 함께 있습니다
지원 데이터: MDN · Web Platform Status (2026-08 확인)

직접 만져보기 — 네이티브 CSS 기본 · 방향 감지의 한계 · 접근성 반영 · 미지원 브라우저 대비

파이어폭스 정식 릴리스에서는 애니메이션이 돌지 않습니다. 그게 정상이고, 그때 무엇이 보이는지가 네 번째 링크입니다.


2) IntersectionObserver: “보이면” 바꾸는 방식

어떻게 동작하나요?

IntersectionObserver는 “이 요소가 뷰포트에 들어왔는지/얼마나 들어왔는지"를 브라우저가 효율적으로 알려주는 API입니다. 스크롤 이벤트를 매 프레임마다 직접 처리하지 않고, 상태 전환 지점을 기준으로 동작하게 만들 때 특히 강합니다.

예시: 등장 애니메이션(reveal on scroll)

html
<section class="io-section">
  <h2 class="io-reveal">스크롤에 따라 자연스럽게 등장</h2>
  <p class="io-reveal">IntersectionObserver는 상태 전환을 다루기에 좋습니다.</p>
</section>
css
.io-reveal {
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 500ms ease, transform 500ms ease;
  will-change: opacity, transform;
}

.io-reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .io-reveal,
  .io-reveal.is-visible {
    transition: none;
    transform: none;
    opacity: 1;
  }
}
js
const revealEls = document.querySelectorAll(".io-reveal");

const observer = new IntersectionObserver(
  (entries, obs) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      entry.target.classList.add("is-visible");
      obs.unobserve(entry.target);
    }
  },
  {
    threshold: 0.2,
  }
);

for (const el of revealEls) observer.observe(el);

장점

  • 성능 대비 효율이 매우 좋습니다
  • “등장/퇴장/활성화” 같은 상태 전환에 강합니다
  • 지원 범위가 넓고, 패턴이 안정적입니다

단점 / 주의점

  • 스크롤 진행률을 “연속값"으로 쓰는 데는 덜 적합합니다
  • 결국 클래스 토글 + CSS 조합으로 귀결되기 쉽습니다
  • 정교한 타임라인 연출에는 한계가 있습니다

직접 만져보기 — IntersectionObserver 기본 · 스크롤 방향 토글 · 접근성 반영

세 번째 링크는 한 번 나타난 요소를 다시 숨기지 않습니다. 되돌아 읽는 사람에게 글이 사라지지 않게요.


3) scroll + requestAnimationFrame: 진행률을 직접 매핑하기

어떻게 동작하나요?

가장 전통적이고, 가장 유연한 방식입니다.

여기서 쓰는 requestAnimationFrame(줄여서 rAF)은 “다음 화면을 그리기 직전에 이 함수를 한 번 실행해달라"고 브라우저에 예약하는 API입니다. 스크롤 이벤트는 한 프레임에 여러 번 들어올 수 있는데, 계산을 rAF로 미뤄두면 프레임당 한 번만 돌게 묶을 수 있어요.

  1. 스크롤 위치를 읽고
  2. 특정 구간의 진행률을 계산한 뒤
  3. 그 진행률을 transform/opacity 같은 스타일에 직접 연결합니다

이 방식은 정확히 원하는 연출을 만들 수 있는 대신, 우리가 성능과 접근성까지 책임져야 합니다.

예시: 구간 진행률로 카드 스케일 조절하기

html
<section class="raf-stage">
  <div class="raf-card" data-raf-card>Scroll drives me</div>
</section>
css
.raf-stage {
  min-height: 160vh;
  display: grid;
  place-items: center;
  padding: 24vh 0;
}

.raf-card {
  width: min(680px, 92vw);
  padding: 48px;
  border-radius: 20px;
  background: #0f172a;
  color: #e5e7eb;
  font-weight: 700;
  font-size: clamp(28px, 4vw, 44px);
  box-shadow: 0 30px 80px rgba(15, 23, 42, 0.35);
  transform: scale(0.92);
  transform-origin: 50% 50%;
  will-change: transform, opacity;
}

@media (prefers-reduced-motion: reduce) {
  .raf-card {
    transform: none !important;
    opacity: 1 !important;
  }
}
js
const card = document.querySelector("[data-raf-card]");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (card && !reduceMotion) {
  let ticking = false;

  const clamp01 = (v) => Math.min(1, Math.max(0, v));

  const update = () => {
    ticking = false;

    const rect = card.getBoundingClientRect();
    const viewportH = window.innerHeight;

    // 카드가 화면 아래에서 들어와 중앙을 지나 위로 나갈 때를 0~1로 정규화
    const start = viewportH;
    const end = -rect.height;
    const progress = clamp01((start - rect.top) / (start - end));

    const scale = 0.92 + progress * 0.08;
    const opacity = 0.6 + progress * 0.4;

    card.style.transform = `scale(${scale.toFixed(4)})`;
    card.style.opacity = opacity.toFixed(4);
  };

  const onScroll = () => {
    if (ticking) return;
    ticking = true;
    window.requestAnimationFrame(update);
  };

  window.addEventListener("scroll", onScroll, { passive: true });
  window.addEventListener("resize", onScroll);
  update();
}

장점

  • 연출 자유도가 가장 높습니다
  • 어떤 수학적 매핑도 가능합니다
  • 기존 코드베이스와 쉽게 섞을 수 있습니다

단점 / 주의점

  • 성능/버벅임/과도한 리플로우를 직접 관리해야 합니다
  • 접근성 대응을 놓치기 쉽습니다
  • 유지보수 난이도가 가장 높아지기 쉽습니다

직접 만져보기 — scroll + rAF 기본 · 방향 토글 · 접근성 반영

화면에 진행률이 퍼센트로 표시되니, 스크롤하면서 숫자가 어떻게 움직이는지 보세요. 앞의 두 방식에는 없는 값입니다.


React에서의 구현: rAF 매핑을 “React스럽게” 감싸기

React에서는 보통 이렇게 접근합니다.

  • 애니메이션 대상은 ref로 잡고
  • 스크롤 핸들러는 useEffect 안에서 등록하고
  • prefers-reduced-motion이면 아예 로직을 건너뜁니다

아래 예시는 “한 구간의 진행률을 직접 매핑"하는 rAF 패턴을 React 훅 형태로 옮긴 것입니다.

tsx
import { useEffect, useRef } from "react";

function clamp01(v: number) {
  return Math.min(1, Math.max(0, v));
}

export default function ScrollMappedCard() {
  const cardRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const card = cardRef.current;
    if (!card) return;

    const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (reduceMotion) {
      // 모션을 끄는 대신, 최종 상태로 바로 보여줍니다.
      card.style.transform = "none";
      card.style.opacity = "1";
      return;
    }

    let rafId = 0;

    const update = () => {
      rafId = 0;

      const rect = card.getBoundingClientRect();
      const viewportH = window.innerHeight;

      const start = viewportH;
      const end = -rect.height;
      const progress = clamp01((start - rect.top) / (start - end));

      const scale = 0.92 + progress * 0.08;
      const opacity = 0.6 + progress * 0.4;

      card.style.transform = `scale(${scale.toFixed(4)})`;
      card.style.opacity = opacity.toFixed(4);
    };

    const onScroll = () => {
      if (rafId) return;
      rafId = window.requestAnimationFrame(update);
    };

    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", onScroll);
    update();

    return () => {
      window.removeEventListener("scroll", onScroll);
      window.removeEventListener("resize", onScroll);
      // 언마운트 직후 예약된 프레임이 남아 있으면 취소합니다.
      if (rafId) window.cancelAnimationFrame(rafId);
    };
  }, []);

  return (
    <section style={{ minHeight: "160vh", display: "grid", placeItems: "center" }}>
      <div
        ref={cardRef}
        style={{
          width: "min(680px, 92vw)",
          padding: "48px",
          borderRadius: "20px",
          background: "#0f172a",
          color: "#e5e7eb",
          fontWeight: 700,
          fontSize: "clamp(28px, 4vw, 44px)",
          transform: "scale(0.92)",
          transformOrigin: "50% 50%",
          willChange: "transform, opacity",
        }}
      >
        Scroll drives me
      </div>
    </section>
  );
}

이 방식의 장점은 명확합니다.

  • “언제 바인딩되고 언제 정리되는지"가 코드로 분명해집니다
  • 컴포넌트 단위로 스크롤 연출을 격리하기 쉽습니다

단점도 분명합니다.

  • 결국 DOM을 직접 만지는 로직이 들어갑니다
  • 여러 개가 중첩되면 성능 관리가 더 중요해집니다

React에서도 결론은 같습니다. “원리 + 비용 + 접근성"을 한 세트로 같이 들고 가는 편이 훨씬 안전합니다.

직접 만져보기 React판 데모는 아직 없습니다. 매핑 로직은 같으니 순수 JS 데모로 동작을 확인하시고, 위 훅 코드를 그대로 옮기시면 됩니다.


어떤 방식을 선택하면 좋을까?

구현 방식 세 가지를 나란히 비교한 카드 - 네이티브 CSS는 스크롤이 곧 타임라인일 때 좋고 JS 없이 끝나지만 지원 범위를 먼저 확인해야 하고, IntersectionObserver는 등장·활성화·트리거에 강하고 구현이 단순하지만 진행률을 연속값으로는 얻지 못하며, scroll과 rAF 직접 매핑은 원하는 대로 다 할 수 있는 대신 짜야 할 코드가 가장 많고 잘못 쓰면 스크롤이 버벅입니다. 맨 아래에는 실제 제품에서는 셋을 섞어 쓴다는 결론이 있습니다
구현 방식 세 가지를 나란히 비교한 카드 - 네이티브 CSS는 스크롤이 곧 타임라인일 때 좋고 JS 없이 끝나지만 지원 범위를 먼저 확인해야 하고, IntersectionObserver는 등장·활성화·트리거에 강하고 구현이 단순하지만 진행률을 연속값으로는 얻지 못하며, scroll과 rAF 직접 매핑은 원하는 대로 다 할 수 있는 대신 짜야 할 코드가 가장 많고 잘못 쓰면 스크롤이 버벅입니다. 맨 아래에는 실제 제품에서는 셋을 섞어 쓴다는 결론이 있습니다
어떤 방식이 더 낫다기보다, 어떤 목적에 맞는지가 더 중요합니다

저는 보통 이렇게 고릅니다.

  • “스크롤이 곧 타임라인"이라는 성격이 강하다 -> 네이티브 CSS 우선 검토
  • “등장/활성화/트리거"가 핵심이다 -> IntersectionObserver
  • “구간별 정교한 연출/수학적 매핑"이 필요하다 -> rAF 직접 매핑

그리고 실제 제품에서는 이 셋을 섞는 경우가 가장 많습니다. 예를 들어:

  • 전체 진행 바는 CSS Scroll-Driven Animation
  • 섹션 등장 연출은 IntersectionObserver
  • 아주 특별한 히어로 연출만 rAF 커스텀

접근성 이슈: 멋있음보다 먼저 체크할 것들

여기까지가 “어떻게 만드느냐"의 이야기였습니다. 그런데 하나 빠뜨릴 수 없는 게 있습니다.

그런데, 접근성을 곁들인..

요리사 모자와 동그란 안경을 쓴 카툰 셰프가 접시를 들어 보이는 일러스트 - 접시 위에는 진행 바가 그려진 브라우저 창이 놓여 있고, 그 옆에 로즈메리 한 줄기가 가니쉬로 곁들여져 있습니다
요리사 모자와 동그란 안경을 쓴 카툰 셰프가 접시를 들어 보이는 일러스트 - 접시 위에는 진행 바가 그려진 브라우저 창이 놓여 있고, 그 옆에 로즈메리 한 줄기가 가니쉬로 곁들여져 있습니다
네, 접근성이요.

스크롤 기반 애니메이션은 잘못 만들면 “보기 좋은 방해물"이 됩니다. 특히 아래 항목은 실무에서 자주 문제가 됩니다.

스크롤 애니메이션 접근성 안전장치 네 가지를 정리한 카드 - 첫째 멀미와 피로는 prefers-reduced-motion 미디어 쿼리로 존중하고, 둘째 읽는 흐름은 애니메이션이 끝나야 글이 보이는 구조를 피하며, 셋째 포커스와 키보드는 Tab으로 이동한 요소가 화면 밖에 숨지 않는지 확인하고, 넷째 성능은 transform과 opacity 위주로 합성 처리되게 씁니다
스크롤 애니메이션 접근성 안전장치 네 가지를 정리한 카드 - 첫째 멀미와 피로는 prefers-reduced-motion 미디어 쿼리로 존중하고, 둘째 읽는 흐름은 애니메이션이 끝나야 글이 보이는 구조를 피하며, 셋째 포커스와 키보드는 Tab으로 이동한 요소가 화면 밖에 숨지 않는지 확인하고, 넷째 성능은 transform과 opacity 위주로 합성 처리되게 씁니다
멋있는 연출보다 먼저 지켜야 할 안전장치들

1) 멀미/피로: prefers-reduced-motion은 기본값처럼 다루기

prefers-reduced-motion은 사용자가 운영체제 설정에서 “동작 줄이기"를 켰는지 브라우저가 알려주는 미디어 쿼리입니다. 전정기관 장애가 있는 분들에게 큰 움직임은 실제로 멀미와 어지럼을 일으키기 때문에, 이건 취향 설정이 아니라 신체적 요구에 가깝습니다.

  • CSS에서는 @media (prefers-reduced-motion: reduce)로 애니메이션/트랜지션을 끕니다
  • JS에서는 matchMedia("(prefers-reduced-motion: reduce)")로 애니메이션 로직 자체를 우회합니다

위 예시 코드들은 이 대응을 이미 포함하고 있습니다.

기준으로 붙잡을 만한 조항도 있습니다. WCAG 2.2의 2.3.3 상호작용으로부터의 애니메이션(Animation from Interactions, 레벨 AAA)은 “상호작용으로 촉발된 모션 애니메이션은, 그것이 기능이나 정보 전달에 필수적이지 않은 한 비활성화할 수 있어야 한다"고 말합니다. 스크롤도 상호작용이니 이 조항의 사정권이에요. 다만 스크롤로 콘텐츠가 화면에 들어오는 움직임 자체는 스크롤의 본질이라 예외로 봅니다. 그 위에 얹은 패럴랙스나 확대·축소 연출이 문제가 되는 거죠.

레벨 AAA라 법정 의무 수준(보통 AA)은 아니지만, 스크롤 애니메이션을 다루는 이상 이 조항이 가리키는 위험은 그대로 우리 것입니다.

한 가지 덧붙이면, 아래 코드들처럼 matchMedia(...).matches한 번만 읽는 방식은 페이지를 켜둔 채 설정을 바꾼 사용자를 놓칩니다. 엄밀하게 가려면 mql.addEventListener("change", ...)로 변화도 받아야 해요. 여기서는 흐름을 보기 위해 한 번 읽는 형태로 두었습니다.

개선 전/후 코드: reduced motion을 실제로 반영하기

아래처럼 “보기엔 잘 동작하지만” 접근성 대응이 빠진 코드가 흔합니다.

개선 전:

js
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    entry.target.classList.add("is-visible");
  }
});

document.querySelectorAll(".io-reveal").forEach((el) => observer.observe(el));

개선 후:

js
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const targets = document.querySelectorAll(".io-reveal");

if (reduceMotion) {
  // 모션을 줄이는 대신, 즉시 읽을 수 있는 상태로 둡니다.
  targets.forEach((el) => el.classList.add("is-visible"));
} else {
  const observer = new IntersectionObserver((entries, obs) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      entry.target.classList.add("is-visible");
      obs.unobserve(entry.target);
    }
  }, { threshold: 0.2 });

  targets.forEach((el) => observer.observe(el));
}

rAF 매핑도 같은 방식으로 나눌 수 있습니다.

개선 전:

js
const card = document.querySelector("[data-raf-card]");
window.addEventListener("scroll", () => {
  const rect = card.getBoundingClientRect();
  const progress = (window.innerHeight - rect.top) / (window.innerHeight + rect.height);
  card.style.transform = `scale(${0.92 + progress * 0.08})`;
});

개선 후:

js
const card = document.querySelector("[data-raf-card]");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (card && !reduceMotion) {
  let ticking = false;
  const clamp01 = (v) => Math.min(1, Math.max(0, v));

  const update = () => {
    ticking = false;
    const rect = card.getBoundingClientRect();
    const start = window.innerHeight;
    const end = -rect.height;
    const progress = clamp01((start - rect.top) / (start - end));
    const scale = 0.92 + progress * 0.08;
    card.style.transform = `scale(${scale.toFixed(4)})`;
  };

  const onScroll = () => {
    if (ticking) return;
    ticking = true;
    window.requestAnimationFrame(update);
  };

  window.addEventListener("scroll", onScroll, { passive: true });
  window.addEventListener("resize", onScroll);
  update();
}

2) “읽는 흐름"을 깨지 않기

스크롤 애니메이션은 종종 콘텐츠의 의미 순서를 망가뜨립니다.

  • 시각적으로는 나중에 등장하지만
  • DOM 순서상으로는 먼저 읽히는 경우
  • 혹은 그 반대

가능하면 의미 순서(문서 구조)와 시각적 순서가 크게 어긋나지 않게 설계하세요. 애니메이션은 의미를 보조해야지, 의미를 뒤틀면 안 됩니다.

3) 포커스와 키보드 사용성

특히 아래는 꼭 피하고 싶습니다.

  • 스크롤에 따라 display: none으로 사라지는 인터랙티브 요소
  • 포커스가 이동했는데 시각적으로는 멀리 사라져버리는 구성

권장 패턴:

  • 숨김이 필요하면 visibility/opacity 중심으로 다루고
  • 포커스 가능한 요소는 스크롤 연출의 희생양으로 만들지 않기

4) 성능도 접근성이다

버벅임은 모두에게 불편하지만, 특히 보조 기술 사용자에게 더 큰 장벽이 됩니다.

  • transform/opacity 위주로 애니메이션 구성
  • 스크롤 핸들러는 passive + rAF 조합
  • 레이아웃 측정(getBoundingClientRect)은 필요한 만큼만

위 예시들이 쓴 will-change는 “이 속성이 곧 변할 거야"라고 브라우저에 미리 알려 별도 레이어를 준비시키는 힌트입니다. 편하지만 요소 수백 개에 상시로 걸어두면 메모리만 잡아먹어요. 연출이 끝나면 걷어내거나, 애초에 애니메이션 직전에만 붙이는 편이 낫습니다.


데모와 외부 공유: 글 밖에서도 재현 가능하게

스크롤 애니메이션은 글로 읽는 것과 직접 굴려보는 것의 차이가 유난히 큽니다. 그래서 각 절 끝에 데모를 붙여뒀고, 전부 한곳에 모아뒀습니다.

방식마다 같은 결과를 세 가지로 만들어두었으니 나란히 비교해보세요.

  • 기본 — 스크롤에 따라 요소가 반응하는 가장 단순한 형태
  • 방향/한계 — 그 방식이 못 하는 일을 일부러 드러낸 것 (네이티브 CSS는 스크롤 방향을 알지 못합니다)
  • 접근성 반영prefers-reduced-motion을 존중하고, 한 번 나타난 내용을 다시 숨기지 않는 형태 (다만 네이티브 CSS는 이 고정이 안 됩니다. 스크롤 진행률에 묶인 애니메이션은 올라가면 반드시 되감기거든요. IntersectionObserver·rAF 데모에서만 진짜로 고정됩니다)

여기에 네이티브 CSS만 하나 더 있습니다 — 미지원 브라우저에서 무엇이 보이는지. 파이어폭스 정식 릴리스로 열면 애니메이션 없이 정적으로 보일 텐데, 그게 고장이 아니라 설계라는 걸 확인하는 용도입니다.


한 장 요약

  • 세 방식 모두 하는 일은 같습니다. 스크롤을 0에서 1 사이 진행률로 읽어 애니메이션에 연결하는 것 — 차이는 그 연결을 누가 하느냐입니다.
  • 네이티브 CSS(animation-timeline: scroll()·view())는 JS가 거의 필요 없지만, 파이어폭스 정식 릴리스가 빠져 Baseline은 아직 제한적 지원입니다. @supports로 감싸고 안 될 때 보일 모습을 정해두세요.
  • IntersectionObserver는 등장·활성화 같은 상태 전환에 강하고 지원 범위가 넓습니다. 대신 진행률을 연속값으로 쓰기에는 맞지 않아요.
  • scroll + rAF는 무엇이든 매핑할 수 있는 대신 성능과 접근성을 우리가 책임집니다. passive 리스너와 rAF로 한 프레임에 한 번만 계산하는 게 기본입니다.
  • 무엇을 쓰든 prefers-reduced-motion은 기본 대응으로 다룹니다(WCAG 2.3.3이 가리키는 위험). 다만 한 번 나타난 내용을 고정하는 건 JS 쪽에서만 됩니다 — 스크롤 진행률에 묶인 CSS 애니메이션은 되돌아가면 반드시 되감깁니다.

마무리: 스크롤은 입력이 아니라 시간일 수도 있습니다

스크롤은 단지 이벤트가 아니라, 애니메이션을 이해하는 또 하나의 시간 축이 될 수 있습니다.

중요한 건 “어떤 기술이 더 멋진가"보다 “어떤 원리로 연결하고, 어떤 비용을 치르는가"를 함께 보는 일이라고 생각해요.

원리, 장단점, 접근성까지 한 세트로 들고 가면 Scroll-Driven Animation은 훨씬 믿을 만한 도구가 됩니다.