feat(nx-dev): honor prefers-reduced-motion (#27541)

Disables animations when browser is set to `prefers-reduced-motion`

Fixes #27114
This commit is contained in:
Isaac Mann 2024-08-20 11:25:29 -04:00 committed by GitHub
parent 27fe4c6401
commit d6c3b24eb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 71 additions and 14 deletions

View File

@ -3,4 +3,5 @@ export * from './lib/blur-fade';
export * from './lib/fit-text'; export * from './lib/fit-text';
export * from './lib/marquee'; export * from './lib/marquee';
export * from './lib/moving-border'; export * from './lib/moving-border';
export * from './lib/prefers-reduced-motion';
export * from './lib/shine-border'; export * from './lib/shine-border';

View File

@ -1,5 +1,6 @@
import { animate, useInView } from 'framer-motion'; import { animate, useInView } from 'framer-motion';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { usePrefersReducedMotion } from './prefers-reduced-motion';
/** /**
* Animates a value and renders it with a specified suffix. * Animates a value and renders it with a specified suffix.
@ -26,13 +27,14 @@ export function AnimateValue({
const ref = useRef<HTMLSpanElement | null>(null); const ref = useRef<HTMLSpanElement | null>(null);
const [isComplete, setIsComplete] = useState<boolean>(false); const [isComplete, setIsComplete] = useState<boolean>(false);
const isInView = useInView(ref); const isInView = useInView(ref);
const shouldReduceMotion = usePrefersReducedMotion();
useEffect(() => { useEffect(() => {
if (!isInView) return; if (!isInView) return;
if (isComplete && once) return; if (isComplete && once) return;
animate(0, num, { animate(0, num, {
duration: 2.5, duration: shouldReduceMotion ? 0 : 2.5,
onUpdate(value) { onUpdate(value) {
if (!ref.current) return; if (!ref.current) return;

View File

@ -7,6 +7,7 @@ import {
Variants, Variants,
} from 'framer-motion'; } from 'framer-motion';
import { ReactNode, useRef } from 'react'; import { ReactNode, useRef } from 'react';
import { usePrefersReducedMotion } from './prefers-reduced-motion';
interface BlurFadeProps { interface BlurFadeProps {
children: ReactNode; children: ReactNode;
@ -62,6 +63,12 @@ export function BlurFade({
visible: { y: -yOffset, opacity: 1, filter: `blur(0px)` }, visible: { y: -yOffset, opacity: 1, filter: `blur(0px)` },
}; };
const combinedVariants = variant || defaultVariants; const combinedVariants = variant || defaultVariants;
const shouldReduceMotion = usePrefersReducedMotion();
if (shouldReduceMotion) {
return;
}
return ( return (
<AnimatePresence> <AnimatePresence>
<motion.div <motion.div

View File

@ -1,4 +1,5 @@
import { cx } from '@nx/nx-dev/ui-primitives'; import { cx } from '@nx/nx-dev/ui-primitives';
import { usePrefersReducedMotion } from './prefers-reduced-motion';
interface MarqueeProps { interface MarqueeProps {
className?: string; className?: string;
@ -33,6 +34,8 @@ export function Marquee({
repeat = 4, repeat = 4,
...props ...props
}: MarqueeProps) { }: MarqueeProps) {
const shouldReduceMotion = usePrefersReducedMotion();
return ( return (
<div <div
{...props} {...props}
@ -53,6 +56,7 @@ export function Marquee({
className={cx('flex shrink-0 justify-around [gap:var(--gap)]', { className={cx('flex shrink-0 justify-around [gap:var(--gap)]', {
'animate-marquee flex-row': !vertical, 'animate-marquee flex-row': !vertical,
'animate-marquee-vertical flex-col': vertical, 'animate-marquee-vertical flex-col': vertical,
'[animation-play-state:paused]': shouldReduceMotion,
'group-hover:[animation-play-state:paused]': pauseOnHover, 'group-hover:[animation-play-state:paused]': pauseOnHover,
'[animation-direction:reverse]': reverse, '[animation-direction:reverse]': reverse,
})} })}

View File

@ -6,6 +6,7 @@ import {
useTransform, useTransform,
} from 'framer-motion'; } from 'framer-motion';
import { ReactNode, useRef } from 'react'; import { ReactNode, useRef } from 'react';
import { usePrefersReducedMotion } from './prefers-reduced-motion';
/** /**
* Creates a moving border effect around the specified component by animating a rectangular SVG element. * Creates a moving border effect around the specified component by animating a rectangular SVG element.
@ -54,6 +55,11 @@ export function MovingBorder({
const transform = useMotionTemplate`translateX(${x}px) translateY(${y}px) translateX(-50%) translateY(-50%)`; const transform = useMotionTemplate`translateX(${x}px) translateY(${y}px) translateX(-50%) translateY(-50%)`;
const shouldReduceMotion = usePrefersReducedMotion();
if (shouldReduceMotion) {
return;
}
return ( return (
<> <>
<svg <svg

View File

@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
const QUERY = '(prefers-reduced-motion: no-preference)';
export function usePrefersReducedMotion(): boolean {
// Default to no-animations, since we don't know what the
// user's preference is on the server.
const [prefersReducedMotion, setPrefersReducedMotion] = useState(true);
useEffect(() => {
const mediaQueryList = window.matchMedia(QUERY);
// Set the true initial value, now that we're on the client:
setPrefersReducedMotion(!window.matchMedia(QUERY).matches);
// Register our event listener
const listener = (event: any) => {
setPrefersReducedMotion(!event.matches);
};
mediaQueryList.addEventListener('change', listener);
return () => {
mediaQueryList.removeEventListener('change', listener);
};
}, []);
return prefersReducedMotion;
}

View File

@ -1,4 +1,5 @@
'use client'; 'use client';
import { usePrefersReducedMotion } from '@nx/nx-dev/ui-animations';
import { SectionHeading } from '@nx/nx-dev/ui-common'; import { SectionHeading } from '@nx/nx-dev/ui-common';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@ -48,6 +49,7 @@ export function AgentNumberOverTime(): JSX.Element {
// Calculate the width of each item // Calculate the width of each item
const itemWidthPercent = remainingPercent / agents.length; const itemWidthPercent = remainingPercent / agents.length;
const shouldReduceMotion = usePrefersReducedMotion();
const variants = { const variants = {
hidden: { hidden: {
opacity: 0, opacity: 0,
@ -58,7 +60,7 @@ export function AgentNumberOverTime(): JSX.Element {
visible: (i: number) => ({ visible: (i: number) => ({
opacity: 1, opacity: 1,
transition: { transition: {
delay: i || 0, delay: shouldReduceMotion ? 0 : i || 0,
}, },
}), }),
}; };
@ -67,8 +69,8 @@ export function AgentNumberOverTime(): JSX.Element {
opacity: 1, opacity: 1,
y: 0, y: 0,
transition: { transition: {
delay: i * 0.035, delay: shouldReduceMotion ? 0 : i * 0.035,
duration: 0.65, duration: shouldReduceMotion ? 0 : 0.65,
ease: 'easeOut', ease: 'easeOut',
when: 'beforeChildren', when: 'beforeChildren',
staggerChildren: 0.3, staggerChildren: 0.3,

View File

@ -8,8 +8,10 @@ import {
import { SectionHeading } from '@nx/nx-dev/ui-common'; import { SectionHeading } from '@nx/nx-dev/ui-common';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { NxCloudIcon } from '@nx/nx-dev/ui-icons'; import { NxCloudIcon } from '@nx/nx-dev/ui-icons';
import { usePrefersReducedMotion } from '@nx/nx-dev/ui-animations';
export function AutomatedAgentsManagement(): JSX.Element { export function AutomatedAgentsManagement(): JSX.Element {
const shouldReduceMotion = usePrefersReducedMotion();
const variants = { const variants = {
hidden: { hidden: {
opacity: 0, opacity: 0,
@ -20,7 +22,7 @@ export function AutomatedAgentsManagement(): JSX.Element {
visible: (i: number) => ({ visible: (i: number) => ({
opacity: 1, opacity: 1,
transition: { transition: {
delay: i || 0, delay: shouldReduceMotion ? 0 : i || 0,
}, },
}), }),
}; };
@ -29,8 +31,8 @@ export function AutomatedAgentsManagement(): JSX.Element {
opacity: 1, opacity: 1,
y: 0, y: 0,
transition: { transition: {
delay: i * 0.035, delay: shouldReduceMotion ? 0 : i * 0.035,
duration: 0.65, duration: shouldReduceMotion ? 0 : 0.65,
ease: 'easeOut', ease: 'easeOut',
when: 'beforeChildren', when: 'beforeChildren',
staggerChildren: 0.3, staggerChildren: 0.3,

View File

@ -2,6 +2,7 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { SectionHeading } from '@nx/nx-dev/ui-common'; import { SectionHeading } from '@nx/nx-dev/ui-common';
import { usePrefersReducedMotion } from '@nx/nx-dev/ui-animations';
/** /**
* Calculate the total number of years worth of compute. * Calculate the total number of years worth of compute.
@ -71,6 +72,8 @@ const stats = [
]; ];
export function Statistics(): JSX.Element { export function Statistics(): JSX.Element {
const shouldReduceMotion = usePrefersReducedMotion();
const variants = { const variants = {
hidden: { hidden: {
opacity: 0, opacity: 0,
@ -81,7 +84,7 @@ export function Statistics(): JSX.Element {
visible: (i: number) => ({ visible: (i: number) => ({
opacity: 1, opacity: 1,
transition: { transition: {
delay: i || 0, delay: shouldReduceMotion ? 0 : i || 0,
}, },
}), }),
}; };
@ -90,8 +93,8 @@ export function Statistics(): JSX.Element {
opacity: 1, opacity: 1,
y: 0, y: 0,
transition: { transition: {
delay: i * 0.25, delay: shouldReduceMotion ? 0 : i * 0.25,
duration: 0.65, duration: shouldReduceMotion ? 0 : 0.65,
ease: 'easeOut', ease: 'easeOut',
when: 'beforeChildren', when: 'beforeChildren',
staggerChildren: 0.3, staggerChildren: 0.3,

View File

@ -14,6 +14,7 @@ import { BentoGrid, BentoGridItem } from './elements/bento-grid';
import { cx } from '@nx/nx-dev/ui-primitives'; import { cx } from '@nx/nx-dev/ui-primitives';
import { animate, motion, useMotionValue, useTransform } from 'framer-motion'; import { animate, motion, useMotionValue, useTransform } from 'framer-motion';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { usePrefersReducedMotion } from '@nx/nx-dev/ui-animations';
export function UnderstandWorkspace(): JSX.Element { export function UnderstandWorkspace(): JSX.Element {
return ( return (
@ -155,6 +156,7 @@ const Caching = () => {
}; };
const FlakyTasks = () => { const FlakyTasks = () => {
const shouldReduceMotion = usePrefersReducedMotion();
const variants = { const variants = {
hidden: { hidden: {
opacity: 0, opacity: 0,
@ -171,8 +173,8 @@ const FlakyTasks = () => {
opacity: 1, opacity: 1,
x: 0, x: 0,
transition: { transition: {
delay: i * 0.2, delay: shouldReduceMotion ? 0 : i * 0.2,
duration: 0.275, duration: shouldReduceMotion ? 0 : 0.275,
ease: 'easeOut', ease: 'easeOut',
when: 'beforeChildren', when: 'beforeChildren',
staggerChildren: 0.3, staggerChildren: 0.3,
@ -439,6 +441,7 @@ const SplitE2eTests = () => {
}; };
const TaskDistribution = () => { const TaskDistribution = () => {
const shouldReduceMotion = usePrefersReducedMotion();
const variants = { const variants = {
hidden: { hidden: {
opacity: 0, opacity: 0,
@ -455,8 +458,8 @@ const TaskDistribution = () => {
opacity: 1, opacity: 1,
x: 0, x: 0,
transition: { transition: {
delay: i * 0.2, delay: shouldReduceMotion ? 0 : i * 0.2,
duration: 0.275, duration: shouldReduceMotion ? 0 : 0.275,
ease: 'easeOut', ease: 'easeOut',
when: 'beforeChildren', when: 'beforeChildren',
staggerChildren: 0.3, staggerChildren: 0.3,
@ -745,6 +748,10 @@ export function Counter({
}) { }) {
const count = useMotionValue(0); const count = useMotionValue(0);
const rounded = useTransform(count, Math.round); const rounded = useTransform(count, Math.round);
const shouldReduceMotion = usePrefersReducedMotion();
if (shouldReduceMotion) {
duration = 0;
}
useEffect(() => { useEffect(() => {
const animation = animate(count, value, { const animation = animate(count, value, {