ForgeWP

Reduced Motion & Animation Safety

Use CSS-first animation, reduced-motion preferences, and static styles to reduce animation and layout problems.

Animation can elevate a website from a static brochure into an engaging, tactile experience. However, unconstrained animation can also cause severe vestibular discomfort for visitors, trigger layout shifts that hurt your Google Core Web Vitals, and introduce heavy JavaScript bundles that slow down mobile devices.

ForgeWP approaches motion with a strict three-tier architecture:

Output
Motion Architecture
├── 1. CSS-First Transitions (Zero JS, 60fps GPU compositor, default choice)
├── 2. Global Accessibility Fallbacks (@media prefers-reduced-motion in globals.css)
└── 3. Cohesive Motion Islands (motion/react, GSAP, Rive with useReducedMotion())

This hierarchy keeps motion focused and helps respect visitor preferences. Include reduced-motion and JavaScript-disabled checks when verifying the exported page.


The Motion Decision Guide

Before writing a single line of JavaScript animation, determine which tool best fits your requirement:

Flowchart
Rendering diagram...

Tier 1: CSS-First Animations (Zero Hydration)

The fastest JavaScript is the JavaScript you never send to the browser. Modern browsers handle transitions and keyframe animations directly on the GPU compositor thread without touching the main JavaScript execution thread.

Tailwind CSS Transitions

For hover states, focus rings, dropdown disclosure transitions, and simple interactive cues, use utility classes from Tailwind CSS. These compile into static stylesheet rules with zero client-side JavaScript cost:

src/components/PostCard.tsx
1import { useWpTitle, useWpExcerpt, useWpPostLink } from "@forgewp/react"
2
3export function PostCard() {
4  const title = useWpTitle()
5  const excerpt = useWpExcerpt()
6  const link = useWpPostLink()
7
8  return (
9    <article className="group relative overflow-hidden rounded-2xl border border-border bg-card p-6 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:border-primary/40 hover:shadow-xl">
10      <h3 className="text-xl font-bold tracking-tight text-foreground group-hover:text-primary transition-colors duration-200">
11        <a href={link} className="focus:outline-none">
12          {title}
13        </a>
14      </h3>
15      <p className="mt-3 text-sm leading-relaxed text-muted-foreground line-clamp-3">
16        {excerpt}
17      </p>
18      <div className="mt-4 flex items-center gap-2 text-sm font-semibold text-primary">
19        <span>Read article</span>
20        <span className="inline-block transition-transform duration-200 group-hover:translate-x-1">
21          &rarr;
22        </span>
23      </div>
24    </article>
25  )
26}

Because PostCard contains only static hooks (useWpTitle, useWpExcerpt, useWpPostLink) and CSS transition classes, ForgeWP's Smart Discovery compiler marks it as 100% static. It compiles directly into a native PHP template with 0 bytes of client JavaScript.


Tier 2: Global Accessibility Stylesheet Reset

Millions of web users experience vestibular disorders, motion sickness, vertigo, or visual migraines triggered by sudden screen movement, parallax scrolling, or scaling effects. Modern operating systems (macOS, iOS, Windows, Android, Linux) allow users to turn on "Reduce Motion" in their accessibility preferences.

ForgeWP includes a universal reduced-motion reset in your global stylesheet that instantly calms all CSS transitions and animations:

src/app/globals.css
1@media (prefers-reduced-motion: reduce) {
2  *,
3  *::before,
4  *::after {
5    animation-duration: 0.01ms !important;
6    animation-iteration-count: 1 !important;
7    scroll-behavior: auto !important;
8    transition-duration: 0.01ms !important;
9  }
10}

What this reset accomplishes:

  1. Instantly disables CSS animations: Infinite spinners, pulsing glow effects, and marquee tickers complete immediately without looping.
  2. Eliminates scroll smoothing: Replaces smooth scroll jumps with immediate native jumps (scroll-behavior: auto !important), preventing vestibular disorientation.
  3. Collapses transitions: Button hover colors and card borders snap instantly to their final active state without multi-second fades.
Should I remove animations completely or just speed them up?

Setting animation-duration: 0.01ms is safer than animation: none. If your components rely on CSS animation events like animationend or transitionend to fire JavaScript callbacks, setting the duration to a fraction of a millisecond allows the event to trigger properly without subjecting the user to visual movement.


Tier 3: The useReducedMotion() Hook

While CSS media queries handle stylesheet rules, they cannot stop JavaScript animation engines (such as Motion, Framer Motion, GSAP, or Canvas loops) from executing physics calculations in a requestAnimationFrame loop.

To adapt complex JavaScript motion to user accessibility preferences, ForgeWP provides the useReducedMotion() hook.

Hook Signature & Behavior

TSX
1import { useReducedMotion } from "@forgewp/react"
2
3const shouldReduceMotion = useReducedMotion() // Returns boolean

Under the hood, ForgeWP implements useReducedMotion with full SSR safety and dynamic live listener support:

How useReducedMotion works internally
1export function useReducedMotion(): boolean {
2  const [reduced, setReduced] = useState(false)
3
4  useEffect(() => {
5    if (typeof window === "undefined") return
6    const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
7    setReduced(mediaQuery.matches)
8
9    const listener = (event: MediaQueryListEvent) => setReduced(event.matches)
10    mediaQuery.addEventListener("change", listener)
11    return () => mediaQuery.removeEventListener("change", listener)
12  }, [])
13
14  return reduced
15}
  • Server-Side Rendering (SSR) Safe: During build time and static template rendering, useReducedMotion() returns false without throwing window is not defined errors.
  • Dynamic System Adaptation: If the visitor toggles their operating system accessibility settings while browsing your site, the change event fires immediately, and the component updates without requiring a page reload.

Accessible Motion Component Example

Here is how to build a hero section that performs a staggered slide-up entrance for standard visitors, while gracefully falling back to a subtle instant fade for visitors requesting reduced motion:

src/components/AccessibleHero.tsx
1import { useReducedMotion, getStaticMotionStyle } from "@forgewp/react"
2import { motion } from "motion/react"
3
4export function AccessibleHero() {
5  const prefersReduced = useReducedMotion()
6
7  // Define full motion vs accessible motion variants
8  const containerVariants = {
9    hidden: { opacity: prefersReduced ? 1 : 0 },
10    visible: {
11      opacity: 1,
12      transition: {
13        staggerChildren: prefersReduced ? 0 : 0.15,
14        duration: prefersReduced ? 0 : 0.6,
15      },
16    },
17  }
18
19  const itemVariants = {
20    hidden: {
21      opacity: 0,
22      y: prefersReduced ? 0 : 30, // Disable translateY when reduced motion is preferred
23    },
24    visible: {
25      opacity: 1,
26      y: 0,
27      transition: {
28        duration: prefersReduced ? 0.2 : 0.5,
29        ease: "easeOut",
30      },
31    },
32  }
33
34  const initialStyle = getStaticMotionStyle({
35    opacity: 0,
36    y: prefersReduced ? 0 : 30,
37  })
38
39  return (
40    <motion.section
41      variants={containerVariants}
42      initial="hidden"
43      whileInView="visible"
44      viewport={{ once: true, margin: "-100px" }}
45      className="py-24 text-center"
46    >
47      <motion.span
48        variants={itemVariants}
49        style={initialStyle}
50        className="inline-block px-3 py-1 text-xs font-semibold uppercase tracking-wider text-primary bg-primary/10 rounded-full"
51      >
52        WordPress Reimagined
53      </motion.span>
54
55      <motion.h1
56        variants={itemVariants}
57        style={initialStyle}
58        className="mt-6 text-5xl font-extrabold tracking-tight text-foreground sm:text-6xl"
59      >
60        Next-Generation Performance
61      </motion.h1>
62
63      <motion.p
64        variants={itemVariants}
65        style={initialStyle}
66        className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground"
67      >
68        Static PHP templates generated directly from your modern React and Tailwind code.
69      </motion.p>
70    </motion.section>
71  )
72}

Eliminating the "First-Paint Blank Screen" Bug

A notorious flaw in standard React server-side rendering with motion libraries is the Enter-Hidden Trap:

The Dangerous Enter-Hidden Pattern
1// Dangerous in standard SSR:
2<motion.div initial={{ opacity: 0 }} whileInView={{ opacity: 1 }}>
3  <h1>Critical Page Content</h1>
4</motion.div>

In standard Next.js or Astro setups, the server bakes style="opacity: 0" directly into the initial HTML. If:

  1. The user is on a slow 3G cellular network and JavaScript takes 3 seconds to download,
  2. An ad blocker or script error prevents JavaScript from executing, or
  3. The component is wrapped in an intersection observer that fails to trigger,

The user is left staring at an invisible, blank white page.

ForgeWP's Compile-Time Solution: visibleFirstPaintProps

ForgeWP permanently solves this issue inside the compiler's hydration engine (enter-hidden-initial.js).

During static template compilation, ForgeWP's AST analyzer inspects all motion components:

Output
Compiler First-Paint Visibility Pipeline
├── 1. Detect Enter-Hidden Styles (opacity: 0, scale !== 1, x !== 0, y !== 0)
├── 2. Detect Variant Names (/^(hidden|initial|start|inactive)$/i)
├── 3. Rewrite for Static First-Paint (initial: false, animate: "visible")
└── 4. Output Fully Visible Server HTML (Zero blank screen vulnerability)

When ForgeWP renders the static PHP template during build time:

  • It overrides enter-hidden states by setting initial: false and animate: "visible".
  • The HTML delivered by WordPress to the visitor paints 100% visible and readable content immediately.
  • When the client-side JavaScript island hydrates in the browser, the interactive motion engine smoothly takes over and attaches interactive hover, gesture, and scroll triggers.

Reduce Style Flashes and Layout Shifts with getStaticMotionStyle()

Check that content stays readable before JavaScript runs. Matching initial server-rendered styles with client styles can reduce a Flash of Unstyled Content (FOUC) or Cumulative Layout Shift (CLS), but fonts, media dimensions, and custom animation code can still cause shifts.

The getStaticMotionStyle() helper takes an initial motion object and converts it into safe, static CSS inline styles during server pre-rendering.

src/components/FeatureCard.tsx
1import { getStaticMotionStyle } from "@forgewp/react"
2import { motion } from "motion/react"
3
4const initialConfig = {
5  opacity: 0,
6  y: 24,
7  scale: 0.98,
8}
9
10export function FeatureCard({ title, description }: { title: string; description: string }) {
11  return (
12    <motion.div
13      initial={initialConfig}
14      animate={{ opacity: 1, y: 0, scale: 1 }}
15      transition={{ duration: 0.4 }}
16      style={getStaticMotionStyle(initialConfig)}
17      className="p-6 rounded-2xl bg-card border border-border shadow-sm"
18    >
19      <h3 className="text-lg font-bold text-foreground">{title}</h3>
20      <p className="mt-2 text-sm text-muted-foreground">{description}</p>
21    </motion.div>
22  )
23}

Supported Properties in getStaticMotionStyle

The helper parses your motion configuration into standard CSS properties:

Table · 3 columns
Property
Type
Generated Server CSS Output
opacity
number
style="opacity: 0;"
y
number | string
style="transform: translateY(24px);" (appends px if numeric)
x
number | string
style="transform: translateX(16px);" (appends px if numeric)
scale
number
style="transform: scale(0.98);"
rotate
number
style="transform: rotate(45deg);"

Multiple transform properties (x, y, scale, rotate) are combined into a single, valid CSS transform declaration automatically.


Cohesive Motion Islands: Why ForgeWP Won't Fragment Your Animations

Normally, ForgeWP's Smart Discovery engine uses an AST splitter (island-split.js) to break mixed components into static shells and tiny interactive micro-islands. For example, a static blog layout with a like button only extracts the like button as an island.

However, motion components are different. Slicing a motion component into partial islands would tear apart:

  • Staggered timeline sequences between parent containers and child elements.
  • Scroll-triggered timelines (ScrollTrigger, whileInView).
  • Shared layout animations (layoutId).
  • CSS flex and grid container relationships.

Automatic Cohesive Island Protection

When ForgeWP's compiler detects any import from the Universal Animation Catalog, it refuses to split the component into micro-islands. Instead, it preserves the entire component as a single, cohesive interactive island:

Auto-detected animation libraries in ForgeWP
1// Any component importing from these packages hydrates cohesively:
2import { motion, AnimatePresence } from "motion/react"      // Framer Motion / Motion One
3import { gsap } from "gsap"                                 // GreenSock
4import { ScrollTrigger } from "gsap/ScrollTrigger"          // GSAP ScrollTrigger
5import { ReactLenis } from "lenis/react"                    // Smooth scroll
6import Lottie from "lottie-react"                           // Lottie vector animations
7import { DotLottieReact } from "@lottiefiles/dotlottie-react"
8import { Canvas } from "@react-three/fiber"                 // 3D Three.js canvas
9import Spline from "@splinetool/react-spline"               // Spline 3D embeds
10import { Rive } from "@rive-app/react-canvas"               // Rive interactive graphics
11import { Swiper, SwiperSlide } from "swiper/react"          // Swiper carousel
12import autoAnimate from "@formkit/auto-animate"             // Zero-config list animations

Recognized Motion Directives & JSX Tags

Even if you wrap an animation library in custom helper functions, the compiler recognizes motion components by detecting these JSX directives and tags:

  • Directives: whileInView, whileHover, whileTap, whileDrag, whileFocus, animate, exit, layoutId, drag, dragConstraints, scrollTrigger, data-aos, data-scroll, stagger, variants.
  • Component Tags: <AnimatePresence>, <LazyMotion>, <Canvas>, <Spline>, <Lottie>, <DotLottieReact>, <Rive>, <Swiper>, <SwiperSlide>, <ReactLenis>, <Lenis>.

When any of these directives or tags appear, ForgeWP bundles the component cleanly into .forgewp-islands/ without fragmenting its DOM hierarchy.


Preventing Cumulative Layout Shift (CLS)

Cumulative Layout Shift occurs when visible elements change position or size as content or JavaScript loads. Layout shift is heavily penalized by Google's search ranking algorithms (Core Web Vitals).

Follow these rules when animating components in ForgeWP:

1. Always Reserve Containing Space

If an interactive gallery, 3D canvas, or animated carousel hydrates only when it enters the viewport (trigger="visible"), reserve its height in the static wrapper so the page does not jump when it mounts:

src/components/MediaHero.tsx
1import { Hydrate } from "@forgewp/react"
2import { Interactive3DCanvas } from "./Interactive3DCanvas"
3
4export function MediaHero() {
5  return (
6    <section className="relative w-full py-12">
7      {/* Reserve height with min-h or aspect ratio to eliminate CLS */}
8      <div className="mx-auto w-full max-w-5xl aspect-video rounded-3xl bg-muted overflow-hidden">
9        <Hydrate trigger="visible" preload="near-visible">
10          <Interactive3DCanvas />
11        </Hydrate>
12      </div>
13    </section>
14  )
15}

2. Animate Composite Properties, Not Layout Properties

Browsers calculate web layouts using three distinct stages: Layout, Paint, and Composite.

Table · 4 columns
Animation Type
Properties
Performance Impact
CLS Risk
Composite (Recommended)
transform, opacity
High (instant 60fps GPU execution)
Zero CLS risk
Paint (Avoid)
background-color, box-shadow
Medium (triggers CPU repaint)
Low CLS risk
Layout (Prohibited)
height, width, top, margin, padding
Poor (forces full document reflow)
High CLS spikes
TSX
1// Incorrect: Animating layout dimensions forces browser reflow
2<motion.div animate={{ height: isOpen ? 200 : 0, marginTop: isOpen ? 16 : 0 }} />
3
4// Recommended: Animate transforms or scale while maintaining layout boundaries
5<motion.div animate={{ opacity: isOpen ? 1 : 0, scaleY: isOpen ? 1 : 0 }} />

Frequently Asked Questions

Does calling `useReducedMotion()` automatically convert my component into an interactive island?

No! useReducedMotion() is listed in ForgeWP's SERVER_SAFE_HOOKS catalog. If a component uses useReducedMotion() purely to choose between two static Tailwind CSS class names (such as reduced ? "opacity-100" : "transition-opacity"), the compiler can still pre-render it statically. However, if the component passes motion props to <motion.div> or initializes a JS animation loop, Smart Discovery automatically hydrates it as an island.

How does Lenis Smooth Scroll integrate with ForgeWP's island hydration?

Smooth scroll engines like Lenis intercept wheel events globally across the window. In ForgeWP, place <ReactLenis root> inside your root layout (src/app/layout.tsx). The compiler detects the ReactLenis tag and registers it as a client-side provider so that page transitions and internal link jumps glide smoothly across the entire site.

Never hide critical content behind animation states without a fallback.

Avoid patterns where text, navigation menus, or forms remain at opacity: 0 until an animation callback fires. If a visitor has JavaScript disabled or is browsing through an accessibility screen reader, they must be able to read and interact with your site without friction.


Next Steps

Now that you understand selective hydration boundaries and accessible animation, proceed to Locale Configuration & Translation Catalogs to discover how ForgeWP connects translated source strings and language-aware content to WordPress.