# Reduce Ambient 3D Motion

**SHOULD** · **ID:** `animations-ambient-motion-reduced` · **Category:** animations
**Source:** Custom
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-ambient-motion-reduced

> SHOULD: prefers-reduced-motion applies to 3D/WebGL, not just CSS — and the rAF loop must read it in JS (matchMedia). Under reduce: stop auto-rotation, disable pointer parallax/tilt, replace scroll-driven camera moves with a static framing, and drop idle ambient motion to zero. Keep the scene interactive on demand (drag-to-orbit is user-initiated and allowed).

Auto-rotating models, parallax tilt, and scroll-driven cameras are motion too — pause or flatten them under prefers-reduced-motion

This is animations-prefers-reduced-motion followed onto the canvas, and it is written here because the 3D ecosystem almost universally forgets it — a survey of the major three.js / R3F skills turned up no reduced-motion rule at all. The reason it gets dropped is structural: the media query lives in CSS while the motion lives in a `requestAnimationFrame` loop, so the loop has to read the preference itself and nobody wires it up. Yet ambient 3D is the MOST provocative motion on the page for a vestibular user — a model idling on a slow auto-rotate, a hero that tilts toward the pointer, a camera that dollies as you scroll: large, continuous, viewport-filling, and self-initiated by the site rather than the user. Under `prefers-reduced-motion: reduce` (check it in JS with `matchMedia`, not only in CSS): stop auto-rotation, disable pointer parallax and tilt, replace scroll-driven camera moves with a static framing or an instant cut, and drop idle ambient motion to zero. Keep the scene interactive on demand — a user who drags to orbit is initiating that motion, which is allowed, the same carve-out WCAG 2.3.3 makes for motion from interaction. Pairs with animations-ibelick-pause-offscreen (don't even run the loop when it is not visible) and performance-webgl-gpu-budget (the cost side of the same loop).

## Rule snippet

```tsx
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) controls.autoRotate = true; // otherwise hold a static frame
```

## Bad — do not do this

`animations-ambient-motion-reduced-bad`

```tsx
import { useState } from 'react';

/**
 * A model idling on a perpetual auto-rotate. The reduced-motion preference is honored
 * nowhere — because the spin lives in a keyframe/loop the media query never touches —
 * so a vestibular user gets viewport-filling motion they cannot turn off.
 */
export function AmbientMotionReducedBad() {
  const [reduce, setReduce] = useState(false);

  return (
    <div className="w-full max-w-sm">
      <style>{`
        @keyframes amr-spin-bad { to { transform: rotateY(360deg); } }
        .amr-cube-bad { animation: amr-spin-bad 5s linear infinite; }
      `}</style>
      <div className="rounded-lg border border-border bg-card p-4">
        <label className="mb-4 flex items-center gap-2 text-xs text-muted-foreground">
          <input
            type="checkbox"
            checked={reduce}
            onChange={(e) => setReduce(e.target.checked)}
            className="size-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
          Simulate prefers-reduced-motion: reduce
        </label>
        <div className="flex justify-center py-6" style={{ perspective: '600px' }}>
          {/* Bug: auto-rotation ignores the preference entirely */}
          <div
            className="amr-cube-bad grid size-20 place-items-center rounded-lg bg-primary font-semibold text-primary-foreground"
            style={{ transformStyle: 'preserve-3d' }}
          >
            3D
          </div>
        </div>
      </div>
      <p className="mt-4 text-xs text-destructive">
        Toggle reduced motion — it keeps spinning. Ambient 3D motion that never reads the preference.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-ambient-motion-reduced-good`

```tsx
import { useEffect, useRef, useState } from 'react';

/**
 * The idle auto-rotate stops under prefers-reduced-motion (the toggle or the real OS
 * setting), but drag-to-orbit keeps working — because user-initiated motion is exactly
 * the carve-out WCAG 2.3.3 allows. Auto-rotation runs on a rAF loop that is never even
 * started while motion is reduced.
 */
export function AmbientMotionReducedGood() {
  const [reduce, setReduce] = useState(false);
  const cubeRef = useRef<HTMLDivElement>(null);
  const angle = useRef(-20);
  const dragging = useRef(false);
  const lastX = useRef(0);

  const apply = () => {
    if (cubeRef.current) cubeRef.current.style.transform = `rotateY(${angle.current}deg)`;
  };

  useEffect(() => {
    apply();
    const osReduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
    const allowed = !reduce && !osReduce;
    if (!allowed) return; // reduced: no loop is ever started
    let raf = 0;
    const tick = () => {
      if (!dragging.current) {
        angle.current += 0.4;
        apply();
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [reduce]);

  const onPointerDown = (e: React.PointerEvent) => {
    dragging.current = true;
    lastX.current = e.clientX;
    e.currentTarget.setPointerCapture?.(e.pointerId);
  };
  const onPointerMove = (e: React.PointerEvent) => {
    if (!dragging.current) return;
    angle.current += (e.clientX - lastX.current) * 0.6;
    lastX.current = e.clientX;
    apply();
  };
  const endDrag = (e: React.PointerEvent) => {
    dragging.current = false;
    e.currentTarget.releasePointerCapture?.(e.pointerId);
  };

  return (
    <div className="w-full max-w-sm">
      <div className="rounded-lg border border-border bg-card p-4">
        <label className="mb-4 flex items-center gap-2 text-xs text-muted-foreground">
          <input
            type="checkbox"
            checked={reduce}
            onChange={(e) => setReduce(e.target.checked)}
            className="size-4 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          />
          Simulate prefers-reduced-motion: reduce
        </label>
        <div className="flex justify-center py-6" style={{ perspective: '600px' }}>
          <div
            ref={cubeRef}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={endDrag}
            onPointerCancel={endDrag}
            className="grid size-20 cursor-grab touch-none select-none place-items-center rounded-lg bg-primary font-semibold text-primary-foreground active:cursor-grabbing"
            style={{ transformStyle: 'preserve-3d', transform: 'rotateY(-20deg)' }}
          >
            3D
          </div>
        </div>
        <p className="text-center text-xs text-muted-foreground">↔ drag the cube to orbit</p>
      </div>
      <p className="mt-4 text-xs text-success">
        Under reduced motion the idle spin never starts — but drag-to-orbit still works, because
        user-initiated motion is allowed.
      </p>
    </div>
  );
}
```

## References

- [MDN — prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion)
- [WCAG 2.3.3 — Animation from Interactions](https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions.html)
- [Poimandres — react-three-a11y](https://github.com/pmndrs/react-three-a11y)
