동작 감소 옵션
Bendd 웹앱에는 다양한 애니메이션이 적용되어 있습니다. 애니메이션을 개발할 때 절대 잊지 말아야 할 사실이 있는데요. 일부 사용자에게 멀미를 유발할 수 있는 x/y
애니메이션을 opacity
로 바꾸거나, 배경 동영상의 자동 재생을 비활성화하거나, 시차 모션을 끄는 등의 애니메이션을 제어할 수 있도록 제공해야 합니다.
그럼 무엇을 기준으로 애니메이션을 비활성화해야 할까요? 다행히 모든 주요 OS에서는 접근성 설정에서 “모션 감소” 설정을 사용할 수 있습니다. 이 설정을 활성화하면 모든 애니메이션(ex: MacOS에서 요술램프 지니 최소화 효과)을 비활성화합니다. Apple은 미디어 쿼리인 prefers-reduced-motion
을 사용해 브라우저에 해당 설정을 노출하기 시작했습니다. 이렇게 하면 웹사이트에서 이 미디어 쿼리를 이용해 명시적으로 애니메이션을 비활성화할 수 있습니다.
보통 전역 스타일로 아래와 같이 구성할 수 있습니다:
@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 라이프사이클에 연결하는 훅을 구현했습니다:
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-motion
의 motion
컴포넌트를 확장해서 usePrefersReducedMotion
훅을 사용해 애니메이션을 선택적으로 비활성화 해 이 과정을 간소화해보겠습니다:
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
훅을 사용해 사용자의 ‘동작 감소’ 선호 여부를 확인하고, 이에 따라 자식 컴포넌트의 애니메이션 속성을 제거하여 반환하는 래퍼 함수로 구현해보겠습니다.
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
컴포넌트는 아래와 같은 과정을 거칩니다:
동작 감소 선호 여부 확인
`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
컴포넌트를 사용할 때와 사용하지 않을 때의 코드를 비교해보세요:
초기 코드
사용자의 reduced motion 설정을 고려한 초기 코드