스크롤 시 패럴랙스 효과 빌드

스크롤 시 패럴랙스 효과는 사용자가 페이지를 아래로 스크롤할 때 다양한 콘텐츠 레이어가 서로 다른 속도로 움직이는 시각적 기법입니다. 이렇게 하면 전경 요소가 배경 요소보다 더 빠르게 움직이는 것처럼 보여 깊이감이 느껴지며, 몰입도 높은 탐색 환경을 제공할 수 있습니다. 이 효과는 애니메이션을 컨테이너의 스크롤 위치에 연결할 수 있는 CSS 스크롤 기반 애니메이션을 사용하여 가장 잘 구현할 수 있습니다.

구현 방법

기본 패럴랙스 효과를 만드는 방법은 다음과 같습니다.

  1. 래퍼 요소 만들기: 이 요소는 패럴랙스 효과의 모든 레이어를 그룹화합니다. 스크롤 가능한 요소가 아니므로 오버플로가 잘려야 합니다. 또한 패럴랙스 효과 레이어 중 하나의 높이와 일치하는 height를 지정합니다.

    <div class="wrapper">
      …
    </div>
    
    .wrapper {
      overflow: clip;
      height: 100vh; /* Height of one of the layers of the parallax */
    }
    
  2. 레이어 선언: 래퍼 내부에 서로 다른 속도로 움직일 개별 레이어를 추가합니다.

    <div class="wrapper">
      <div class="layer">LAYER 0</div>
      <div class="layer">LAYER 1</div>
      <div class="layer">LAYER 2</div>
      …
    </div>
    
  3. 변환 애니메이션 추가: 레이어의 transform 속성을 변경하는 CSS 애니메이션을 정의합니다. 패럴랙스 효과의 경우 일반적으로 translateY를 사용하여 레이어를 세로로 이동합니다.

    @keyframes parallax {
      from {
        transform: translateY(700px);
      }
    }
    
  4. view-timeline 설정: 애니메이션을 스크롤 위치에 연결하려면 래퍼 요소에 view-timeline를 만든 다음 레이어에 적용합니다.

    .wrapper {
      view-timeline: --wrapper;
    }
    
    .layer {
      animation: parallax linear both;
      animation-timeline: --wrapper;
    }
    
  5. 애니메이션 시차 적용: 레이어가 서로 다른 속도로 움직이게 하려면 키프레임에 시차 적용 또는 animation-range에 시차 적용이라는 두 가지 주요 접근 방식 중 하나를 사용할 수 있습니다.

    두 접근 방식 모두 하드 코딩된 값을 사용하거나 sibling-index()/sibling-count() 구현을 사용할 수 있습니다. 하드 코딩된 값은 가장 간단하며 레이어 수가 제한된 경우에도 유용합니다. sibling-index()/sibling-count() 구현은 레이어가 많을 때 유용합니다.

    • 키프레임의 시차:

      하드코딩된 값을 사용하여 각 레이어의 맞춤 속성을 정의하여 패럴랙스 오프셋을 수동으로 제어할 수 있습니다.

      .layer:nth-child(1) { --offset: 100px; }
      .layer:nth-child(2) { --offset: 200px; }
      .layer:nth-child(3) { --offset: 300px; }
      
      @keyframes parallax {
        from {
          transform: translateY(var(--offset));
        }
      }
      

      sibling-index()를 사용하여 sibling-index() 함수가 하위 요소의 색인을 형제 요소 중에서 반환하도록 하여 시차 효과를 자동으로 계산합니다.

      @keyframes parallax {
        from {
          transform: translateY(calc(100px * sibling-index()));
        }
      }
      
    • animation-range 시차 적용:

      하드 코딩된 값을 사용하면 각 레이어에서 animation-range의 경계를 개별적으로 명시적으로 정의할 수 있습니다.

      .layer:nth-child(1) { animation-range: entry 25% exit 50%; }
      .layer:nth-child(2) { animation-range: entry 25% exit 75%; }
      .layer:nth-child(3) { animation-range: entry 25% exit 100%; }
      

      sibling-index() 및 sibling-count()를 사용하면 총 레이어 수 (sibling-count())를 기준으로 범위를 수학적으로 계산할 수 있습니다.

      .layer {
        animation-range: entry 25% exit calc(100% / sibling-count() * sibling-index());
      }
      

예시 코드

@keyframes parallax {
  from {
    transform: translateY(calc(100px * sibling-index()));
  }
}

.wrapper {
  view-timeline: --wrapper;
}

.layer {
  animation: parallax linear both;
  animation-timeline: --wrapper;
}

@media (prefers-reduced-motion: reduce) {
  .layer {
    animation: none;
  }
}

또는 animation-range 속성을 사용하여 비슷한 효과를 얻을 수 있습니다.

@keyframes parallax {
  from {
    transform: translateY(700px);
  }
}

.wrapper {
  view-timeline: --wrapper;
}

.layer {
  animation: parallax linear both;
  animation-timeline: --wrapper;
  animation-range: entry 25% exit calc(100% / sibling-count() * sibling-index());
}

@media (prefers-reduced-motion: reduce) {
  .layer {
    animation: none;
  }
}

권장사항

스크롤 기반 애니메이션을 사용할 때는 원활하고 접근성 높은 환경을 보장하기 위해 몇 가지 권장사항을 따르는 것이 중요합니다.

  • 권장사항: 기능 감지를 포함합니다. 모든 브라우저가 스크롤 기반 애니메이션을 지원하는 것은 아닙니다. @supports ((animation-timeline: view()) and (animation-range: entry))를 사용하여 지원 여부를 확인하고 지원하지 않는 브라우저에 대체 기능을 제공합니다.
    • 일부만 지원하는 브라우저를 필터링하려면 여기에 (animation-range: entry) 검사를 포함해야 합니다(MUST).
    • scroll-timeline-polyfill 패키지는 기능이 완전하지 않고 알려진 문제가 많으므로 대체 전략에 사용하지 마세요.
    • 애니메이션이 장식용으로만 간주되는 경우 점진적 개선을 선택하고 대체 기능을 제공하지 마세요.
  • 권장사항 사용자 환경설정 존중: 일부 사용자는 웹에서 모션이 적은 것을 선호합니다. prefers-reduced-motion 미디어 쿼리를 사용하여 이러한 사용자의 애니메이션을 사용 중지하거나 줄입니다.
  • 권장사항: 성능이 우수한 CSS 속성에만 애니메이션을 적용하세요. 가장 부드러운 애니메이션을 구현하려면 브라우저의 컴포지터 스레드에서 처리할 수 있는 속성(예: transform 및 opacity)에만 애니메이션을 적용하세요. width 또는 height와 같은 다른 속성에 애니메이션을 적용하면 성능 문제가 발생할 수 있습니다.
  • 권장사항: 올바른 선언 순서를 사용합니다. animation 약식 속성을 사용하는 경우 약식이 타임라인을 재설정하지 않도록 animation-timeline 및 animation-range를 뒤에 선언합니다.

animation-range 설정:

  • 모든 레이어에 동일한 시작 오프셋을 지정합니다(예: entry 25%).권장
  • 권장사항: 모든 레이어에 sibling-count() 및 sibling-index()를 사용하여 오프셋을 분산하는 다른 종료 오프셋을 지정합니다(예: exit calc(100% / sibling-count() * sibling-index())).

브라우저 지원 및 대체 전략

따라서 일반적으로 대체 전략이 필요합니다.

스크롤 기반 애니메이션을 지원하지 않는 브라우저의 경우 대체 기능을 사용하여 시각 효과를 다시 만들 수 있습니다. 대체는 일반적으로 스크롤 리스너 (ScrollTimeline 효과의 경우) 또는 IntersectionObserver API (ViewTimeline 효과의 경우)를 사용하여 빌드됩니다.

스크롤 기반 애니메이션을 기본적으로 지원하는 브라우저에서는 항상 네이티브 CSS 구현을 사용하세요. 성능이 더 우수합니다.

대체 접근 방식을 사용하여 모든 효과를 다시 만들 수 있는 것은 아닙니다.

이 사용 사례의 경우 다음 스크립트는 스크롤 기반 애니메이션을 지원하지 않는 브라우저에 대체 기능을 적용합니다. IntersectionObserver를 사용하여 .wrapper 요소의 공개 상태를 추적하고 스크롤 위치에 따라 레이어의 transform 속성을 업데이트합니다.

// Fallback for browsers that don't support scroll-driven animations
if (!CSS.supports('(animation-timeline: view()) and (animation-range: entry)')) {
  const wrapper = document.querySelector('.wrapper');
  const layers = document.querySelectorAll('.layer');

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        window.addEventListener('scroll', onScroll);
      } else {
        window.removeEventListener('scroll', onScroll);
      }
    });
  }, { threshold: 0 });

  observer.observe(wrapper);

  function onScroll() {
    const scrollY = window.scrollY;
    const wrapperRect = wrapper.getBoundingClientRect();
    const wrapperTop = wrapperRect.top + scrollY;
    const wrapperHeight = wrapperRect.height;
    const windowHeight = window.innerHeight;

    if (scrollY >= wrapperTop - windowHeight && scrollY <= wrapperTop + wrapperHeight) {
      const scrollPercent = (scrollY - (wrapperTop - windowHeight)) / (wrapperHeight + windowHeight);
      
      layers.forEach((layer, index) => {
        // This matches the effect as defined in the CSS example above.
        // Customize this further if needed.
        const initialTranslateY = 100 * index;
        const translateY = initialTranslateY * (1 - scrollPercent);
        layer.style.transform = `translateY(${translateY}px)`;
      });
    }
  }

  // Trigger onScroll once to set initial positions
  onScroll();
}