사용자의 동작 감소 옵션을 존중하는 애니메이션

24. 06. 18. (2개월 전)

TL;DR: React와 framer-motion을 사용하여 사용자의 '동작 감소' 옵션을 존중하는 애니메이션을 구현하는 방법을 소개합니다. 사용자의 접근성을 향상시키고, 애니메이션을 효과적으로 관리하는 방법을 알아보세요.

동작 감소 옵션

Bendd 웹앱에는 다양한 애니메이션이 적용되어 있습니다. 애니메이션을 개발할 때 절대 잊지 말아야 할 사실이 있는데요. 일부 사용자에게 멀미를 유발할 수 있는 x/y 애니메이션을 opacity로 바꾸거나, 배경 동영상의 자동 재생을 비활성화하거나, 시차 모션을 끄는 등의 애니메이션을 제어할 수 있도록 제공해야 합니다.

그럼 무엇을 기준으로 애니메이션을 비활성화해야 할까요? 다행히 모든 주요 OS에서는 접근성 설정에서 “모션 감소” 설정을 사용할 수 있습니다. 이 설정을 활성화하면 모든 애니메이션(ex: MacOS에서 요술램프 지니 최소화 효과)을 비활성화합니다. Apple은 미디어 쿼리인 prefers-reduced-motion 을 사용해 브라우저에 해당 설정을 노출하기 시작했습니다. 이렇게 하면 웹사이트에서 이 미디어 쿼리를 이용해 명시적으로 애니메이션을 비활성화할 수 있습니다.

보통 전역 스타일로 아래와 같이 구성할 수 있습니다:

reduced-motion.css
@media (prefers-reduced-motion: reduce) {
  * {
    -webkit-animation-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    -webkit-animation-iteration-count: 1 !important;
    animation-iteration-count: 1 !important;
    -webkit-transition-duration: 0.01ms !important;
    -o-transition-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

이 코드만으로 모든 애니메이션의 동작이 줄어들면 좋겠지만, 아쉽게도 애니메이션이 전적으로 CSS로 동작(eg. transitions, keyframe animations)할 때만 잘 실행됩니다. 커서 좌표에 따른 애니메이션 등 CSS를 통해서만 애니메이션을 만들 수 없는 유형은 JS에서 애니메이션을 실행하는데 이 경우엔 의도한 대로 동작하지 않습니다.

이 경우, 사용자의 “모션 감소” 설정 여부를 파악해서 직접 로직에서 애니메이션이 실행되지 않도록 해야 합니다. 그래서 사용자가 OS에서 “모션 감소” 체크박스를 토글하면 콜백 함수를 실행해 React 라이프사이클에 연결하는 훅을 구현했습니다:

use-prefers-reduced-motion.ts
import { useSyncExternalStore } from 'react';
 
const QUERY = '(prefers-reduced-motion: reduce)';
 
const subscribePrefersReducedMotion = (onStoreChange: () => void) => {
  const mediaQuery = window.matchMedia(QUERY);
  mediaQuery.addEventListener('change', onStoreChange);
 
  return () => mediaQuery.removeEventListener('change', onStoreChange);
};
 
const getPrefersReducedMotionSnapshot = () => {
  return window.matchMedia(QUERY).matches;
};
 
const getServerSnapshot = () => {
  return undefined;
};
 
export const usePrefersReducedMotion = () => {
  return useSyncExternalStore(
    subscribePrefersReducedMotion,
    getPrefersReducedMotionSnapshot,
    getServerSnapshot
  );
};

사용자의 기기에서 모션 감소 설정이 활성화된 경우 true를 반환하는 훅입니다. 이 훅으로 JS로 애니메이션을 적용한 요소도 쉽게 제어할 수 있습니다:

export function ThemeSwitcher() {
  const prefersReducedMotion = usePrefersReducedMotion();
 
  return (
    <motion(Sun)
      className="block size-1/2 dark:hidden"
      initial={{ rotate: 0 }}
      animate={{
        rotate: isSystemDark ? (prefersReducedMotion ? 0 : -90) : 0,
      }}
    />
  );
};

조금 더 편하게 사용해보기

여전히 usePrefersReducedMotion 훅을 통해 반환된 값을 이용해 애니메이션 관련 속성에 삼항 연산자를 이용해 값을 조정하는 과정이 필요합니다. framer-motionmotion컴포넌트를 확장해서 usePrefersReducedMotion 훅을 사용해 애니메이션을 선택적으로 비활성화 해 이 과정을 간소화해보겠습니다:

ReducedMotionDiv.tsx
import { usePrefersReducedMotion } from '@/hooks/use-prefers-reduced-motion';
import { motion as Motion, type HTMLMotionProps } from 'framer-motion';
import { forwardRef } from 'react';
 
const ReducedMotionDiv = forwardRef<HTMLDivElement, HTMLMotionProps<'div'>>(
  ({ ...motionProps }, ref) => {
    const prefersReducedMotion = usePrefersReducedMotion();
 
    const handler: ProxyHandler<typeof motionProps> = {
      get: function (target, prop, receiver) {
        if (
          typeof prop === 'string' &&
          prefersReducedMotion &&
          shouldRemoveProp(prop)
        ) {
          return undefined;
        }
 
        return Reflect.get(target, prop, receiver);
      },
    };
 
    const shouldRemoveProp = (prop: string) => {
      const propsToRemove = [
        'animate',
        'initial',
        'transition',
        'variants',
        'whileDrag',
        'whileFocus',
        'whileHover',
        'whileInView',
        'whileTap',
        'onMouseMove',
        'onMouseLeave',
      ];
      return propsToRemove.includes(prop);
    };
 
    const proxiedProps = new Proxy(motionProps, handler);
 
    return <Motion.div {...proxiedProps} ref={ref} />;
  }
);
 
ReducedMotionDiv.displayName = 'ReducedMotionDiv';
 
export default ReducedMotionDiv;

Proxy를 사용해서 motion.div의 속성 액세스를 가로채고, prefersReducedMotion: true일 때 애니메이션 관련 속성을 undefined로 설정합니다. Reflect 클래스의 도움으로 motion.div의 원래 동작은 유지하면서 필요에 따라 애니메이션을 비활성화할 수 있습니다. (Promise 기반 브라우저 익스텐션 API를 사용하는 확장 프로그램을 개발할 때 프록시를 사용했었기에 큰 어려움은 없었습니다.)

현재 구현에 한 가지 불편한 점이 있습니다. div 요소가 아닌 다른 요소를 렌더링할 수 없다는 점인데요. 이를 보완하겠습니다. 여기엔 motion 컴포넌트를 확장하는 등 여러가지 방법이 있습니다. 그러나 반드시 모든 애니메이션을 비활성화 할 필요는 없다는 점을 기억해야 합니다. 대부분 화면이 반짝이는 플래시 효과나 x/y 애니메이션을 비활성화하고 배경색이 천천히 움직이는 애니메이션은 활성화해도 무방합니다.

따라서 저는 애니메이션 비활성화 선택지를 줄 수 있게 motion 컴포넌트를 자식으로 받아, usePrefersReducedMotion 훅을 사용해 사용자의 ‘동작 감소’ 선호 여부를 확인하고, 이에 따라 자식 컴포넌트의 애니메이션 속성을 제거하여 반환하는 래퍼 함수로 구현해보겠습니다.

MotionSlot.tsx
import { Children, cloneElement, isValidElement, type ReactNode } from 'react';
import { isMotionComponent } from 'framer-motion';
import { usePrefersReducedMotion } from '@/hooks/use-prefers-reduced-motion';
 
type MotionSlotProps = {
  children: ReactNode | ReactNode[];
}
 
const shouldRemoveProp = (prop: string) => {
  const propsToRemove = [
    'animate',
    'initial',
    'transition',
    ...
  ];
  return propsToRemove.includes(prop);
};
 
export const MotionSlot = ({ children }: MotionSlotProps) => {
  const prefersReducedMotion = usePrefersReducedMotion();
 
  const processChild = (child: ReactNode): ReactNode => {
    if (isValidElement(child) && isMotionComponent(child.type)) {
      const props = Object.keys(child.props).reduce(
        (acc: Record<string, any>, key) => {
          if (shouldRemoveProp(key) && prefersReducedMotion) {
            acc[key] = undefined;
          } else {
            acc[key] = child.props[key];
          }
          return acc;
        },
        {}
      );
 
      return cloneElement(child, props);
    }
    return child;
  };
 
  const processedChildren = Children.map(children, processChild);
 
  return <>{processedChildren}</>;
};
 
MotionSlot.displayName = 'MotionSlot';

MotionSlot 컴포넌트는 아래와 같은 과정을 거칩니다:

1 / 5

동작 감소 선호 여부 확인

`usePrefersReducedMotion` 훅을 통해 사용자가 동작 감소 옵션의 선호 여부를 확인합니다.

비하인드 스토리로, 원래 motion 컴포넌트 여부를 판별할 때 브라우저 콘솔에 출력된 자식 요소의 심볼 값을 바탕으로 판별했는데요:

if (isValidElement(child) && Symbol('motionComponentSymbol')) {...}

그러나 이 구현은 해당 패키지의 관리자가 값을 변경하면 의도한 대로 동작하지 않게 됩니다. 이러한 위험을 방지하고자 framer-motion 코드를 훑기 시작했습니다. 그리고 생성된 Symbol 값을 바탕으로 isMotionComponent 함수를 제공한다는 것을 알아냈고, 안전한 조건문을 구현할 수 있었습니다.

이제 아래와 같이 div 뿐만 아니라, 다양한 요소를 렌더링할 수 있게 되었습니다:

<MotionSlot>
  <motion.span whileTap={}>Span!</motion.span>
</MotionSlot>
 
<MotionSlot>
  <motion.a href='' whileTap={}>Anchor!</motion.span>
</MotionSlot>

그리고 사용자의 동작 감소 선호 여부를 매번 확인하고 로직을 별도로 작성하지 않아도 됩니다. 아래 영상을 통해 사용자 선호 감소 옵션이 활성화되면 자동으로 애니메이션이 비활성화되는 것을 확인할 수 있습니다.

아래에서 MotionSlot 컴포넌트를 사용할 때와 사용하지 않을 때의 코드를 비교해보세요:

1 / 2

초기 코드

사용자의 reduced motion 설정을 고려한 초기 코드