Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions packages/framer-motion/src/render/VisualElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,17 +410,22 @@ export abstract class VisualElement<

this.values.forEach((value, key) => this.bindToMotionValue(key, value))

if (!hasReducedMotionListener.current) {
initPrefersReducedMotion()
/**
* Determine reduced motion preference. Only initialize the matchMedia
* listener if we actually need the dynamic value (i.e., when config
* is neither "never" nor "always").
*/
if (this.reducedMotionConfig === "never") {
this.shouldReduceMotion = false
} else if (this.reducedMotionConfig === "always") {
this.shouldReduceMotion = true
} else {
if (!hasReducedMotionListener.current) {
initPrefersReducedMotion()
}
this.shouldReduceMotion = prefersReducedMotion.current
}

this.shouldReduceMotion =
this.reducedMotionConfig === "never"
? false
: this.reducedMotionConfig === "always"
? true
: prefersReducedMotion.current

if (process.env.NODE_ENV !== "production") {
warnOnce(
this.shouldReduceMotion !== true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { render } from "../../../jest.setup"
import { motion } from "../../../render/components/motion"
import { MotionConfig } from "../../../components/MotionConfig"
import { hasReducedMotionListener } from "../state"

describe("reduced motion listener initialization", () => {
beforeEach(() => {
// Reset the listener state before each test
hasReducedMotionListener.current = false
})

test("should not initialize listener when reducedMotionConfig is 'never'", () => {
const Component = () => (
<MotionConfig reducedMotion="never">
<motion.div animate={{ opacity: 1 }} />
</MotionConfig>
)

render(<Component />)

// When reducedMotionConfig is "never", the listener should not be initialized
expect(hasReducedMotionListener.current).toBe(false)
})

test("should not initialize listener when reducedMotionConfig is 'always'", () => {
const Component = () => (
<MotionConfig reducedMotion="always">
<motion.div animate={{ opacity: 1 }} />
</MotionConfig>
)

render(<Component />)

// When reducedMotionConfig is "always", the listener should not be initialized
expect(hasReducedMotionListener.current).toBe(false)
})

test("should initialize listener when reducedMotionConfig is 'user'", () => {
const Component = () => (
<MotionConfig reducedMotion="user">
<motion.div animate={{ opacity: 1 }} />
</MotionConfig>
)

render(<Component />)

// When reducedMotionConfig is "user", the listener should be initialized
// to detect the user's prefers-reduced-motion setting
expect(hasReducedMotionListener.current).toBe(true)
})

test("should not initialize listener with default config (defaults to 'never')", () => {
// The default MotionConfigContext has reducedMotion: "never"
const Component = () => <motion.div animate={{ opacity: 1 }} />

render(<Component />)

// Default context has reducedMotion: "never", so no listener
expect(hasReducedMotionListener.current).toBe(false)
})

test("should initialize listener only once across multiple components with 'user' config", () => {
hasReducedMotionListener.current = false

const Component = () => (
<MotionConfig reducedMotion="user">
<motion.div animate={{ opacity: 1 }} />
<motion.div animate={{ x: 100 }} />
<motion.div animate={{ scale: 1.5 }} />
</MotionConfig>
)

render(<Component />)

// The listener should have been initialized once
expect(hasReducedMotionListener.current).toBe(true)
})

test("mixed configurations - 'never' and 'always' do not trigger listener", () => {
hasReducedMotionListener.current = false

const Component = () => (
<>
<MotionConfig reducedMotion="never">
<motion.div data-testid="never" animate={{ opacity: 1 }} />
</MotionConfig>
<MotionConfig reducedMotion="always">
<motion.div data-testid="always" animate={{ opacity: 1 }} />
</MotionConfig>
</>
)

render(<Component />)

// Neither "never" nor "always" should trigger listener initialization
expect(hasReducedMotionListener.current).toBe(false)
})

test("'user' config triggers listener, explicit 'never'/'always' do not", () => {
hasReducedMotionListener.current = false

const ComponentWithNever = () => (
<MotionConfig reducedMotion="never">
<motion.div animate={{ opacity: 1 }} />
</MotionConfig>
)

const ComponentWithUser = () => (
<MotionConfig reducedMotion="user">
<motion.div animate={{ opacity: 1 }} />
</MotionConfig>
)

// First render with "never" - should not initialize listener
const { unmount: unmount1 } = render(<ComponentWithNever />)
expect(hasReducedMotionListener.current).toBe(false)
unmount1()

// Then render with "user" - should initialize listener
hasReducedMotionListener.current = false
render(<ComponentWithUser />)
expect(hasReducedMotionListener.current).toBe(true)
})
})