docs: fix motion skill examples

Fix copied example issues from the adopted #1780 motion skills: live reduced-motion config, tokenized distances/easing/springs, valid shimmer skeleton JSX, and visibility cleanup.
This commit is contained in:
Affaan Mustafa 2026-05-12 01:35:35 -04:00 committed by Affaan Mustafa
parent f219a90f20
commit cb2a70ce72
3 changed files with 96 additions and 51 deletions

View File

@ -278,7 +278,7 @@ import { motionTokens } from "@/lib/motion-tokens"
d="M 0 100 Q 50 0 100 100" d="M 0 100 Q 50 0 100 100"
initial={{ pathLength: 0, opacity: 0 }} initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }} animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: motionTokens.duration.slow, ease: "easeInOut" }} transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/> />
``` ```
@ -336,12 +336,13 @@ const { ref, style } = useScrollReveal()
"use client" "use client"
import { useEffect } from "react" import { useEffect } from "react"
import { motion, useMotionValue, useSpring } from "motion/react" import { motion, useMotionValue, useSpring } from "motion/react"
import { springs } from "@/lib/motion-tokens"
export function CursorFollower() { export function CursorFollower() {
const x = useMotionValue(-100) const x = useMotionValue(-100)
const y = useMotionValue(-100) const y = useMotionValue(-100)
const sx = useSpring(x, { stiffness: 120, damping: 16 }) const sx = useSpring(x, springs.gentle)
const sy = useSpring(y, { stiffness: 120, damping: 16 }) const sy = useSpring(y, springs.gentle)
useEffect(() => { useEffect(() => {
const move = (e: MouseEvent) => { x.set(e.clientX); y.set(e.clientY) } const move = (e: MouseEvent) => { x.set(e.clientX); y.set(e.clientY) }
@ -363,31 +364,46 @@ export function CursorFollower() {
```tsx ```tsx
"use client" "use client"
import { motion } from "motion/react" import { useEffect } from "react"
import { motion, useAnimation } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
export function ShimmerSkeleton({ className }: { className?: string }) { export function ShimmerSkeleton({ className = "" }: { className?: string }) {
const controls = useAnimation() const controls = useAnimation()
useEffect(() => { useEffect(() => {
const run = () => const play = () =>
controls.start({ x: ["-100%", "100%"], transition: { repeat: Infinity, duration: 1.2, ease: "linear" } }) controls.start({
const onVis = () => (document.visibilityState === "hidden" ? controls.stop() : run()) x: ["-100%", "100%"],
run() transition: {
document.addEventListener("visibilitychange", onVis) repeat: Infinity,
return () => document.removeEventListener("visibilitychange", onVis) duration: motionTokens.duration.crawl,
ease: motionTokens.easing.linear,
},
})
const handleVisibility = () => {
if (document.visibilityState === "hidden") controls.stop()
else void play()
}
void play()
document.addEventListener("visibilitychange", handleVisibility)
return () => {
controls.stop()
document.removeEventListener("visibilitychange", handleVisibility)
}
}, [controls]) }, [controls])
return ( return (
<div className={`relative overflow-hidden bg-gray-200 rounded ${className}`}> <div className={`relative overflow-hidden bg-gray-200 rounded ${className}`}>
<motion.div <motion.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/60 to-transparent" className="absolute inset-0 bg-gradient-to-r from-transparent via-white/60 to-transparent"
animate={{ x: ["-100%", "100%"] }} initial={{ x: "-100%" }}
transition={{ repeat: Infinity, duration: 1.2, ease: "linear" }}
animate={controls} animate={controls}
/> />
</div> </div>
) )
}
</div>
)
} }
``` ```
@ -443,8 +459,9 @@ export function LoadingButton({
```tsx ```tsx
"use client" "use client"
import { useEffect, useRef } from "react" import { useEffect } from "react"
import { motion, useAnimation } from "motion/react" import { motion, useAnimation } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
export function PulseDot() { export function PulseDot() {
const controls = useAnimation() const controls = useAnimation()
@ -454,19 +471,23 @@ export function PulseDot() {
controls.start({ controls.start({
scale: [1, 1.4, 1], scale: [1, 1.4, 1],
opacity: [1, 0.6, 1], opacity: [1, 0.6, 1],
transition: { repeat: Infinity, duration: 1.8 }, transition: { repeat: Infinity, duration: motionTokens.duration.crawl },
}) })
// Rule 2: pause when tab is hidden // Rule 2: pause when tab is hidden
const handleVisibility = () => { const handleVisibility = () => {
if (document.visibilityState === "hidden") controls.stop() if (document.visibilityState === "hidden") controls.stop()
else pulse() else void pulse()
} }
pulse() void pulse()
document.addEventListener("visibilitychange", handleVisibility) document.addEventListener("visibilitychange", handleVisibility)
return () => document.removeEventListener("visibilitychange", handleVisibility) // Rule 7 // Rule 7: stop controls and remove listeners on unmount.
}, []) return () => {
controls.stop()
document.removeEventListener("visibilitychange", handleVisibility)
}
}, [controls])
return <motion.span className="w-2 h-2 rounded-full bg-green-400" animate={controls} /> return <motion.span className="w-2 h-2 rounded-full bg-green-400" animate={controls} />
} }

View File

@ -27,7 +27,7 @@ This skill produces:
- A shared `motionTokens` object (duration, easing, distance, scale) - A shared `motionTokens` object (duration, easing, distance, scale)
- A shared `springs` preset map (5 named configs) - A shared `springs` preset map (5 named configs)
- A `shouldAnimate` boolean gate used by all components - A `shouldAnimate()` gate used by all components
- Accessibility-compliant animation defaults via `useReducedMotion` - Accessibility-compliant animation defaults via `useReducedMotion`
- SSR-safe initial states with zero hydration warnings - SSR-safe initial states with zero hydration warnings
@ -79,7 +79,7 @@ These are non-negotiable. They apply to every component in the system.
### When to disable animation entirely ### When to disable animation entirely
Disable (set `shouldAnimate = false`) when: Disable (make `shouldAnimate()` return `false`) when:
- `prefersReduced` is `true` - `prefersReduced` is `true`
- `isLowEnd` is `true` and the animation is non-essential - `isLowEnd` is `true` and the animation is non-essential
@ -134,20 +134,28 @@ export const springs = {
```ts ```ts
// lib/motion-config.ts // lib/motion-config.ts
export const motionConfig = { export const motionConfig = {
isLowEnd: isLowEnd() {
return (
typeof navigator !== "undefined" && typeof navigator !== "undefined" &&
navigator.hardwareConcurrency <= 4, navigator.hardwareConcurrency <= 4
)
prefersReduced:
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
get shouldAnimate() {
return !this.prefersReduced
}, },
get duration() { prefersReduced() {
return this.isLowEnd || this.prefersReduced return (
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
},
shouldAnimate({ essential = false } = {}) {
if (this.prefersReduced()) return false
if (!essential && this.isLowEnd()) return false
return true
},
duration() {
return this.isLowEnd() || this.prefersReduced()
? motionTokens.duration.instant ? motionTokens.duration.instant
: motionTokens.duration.normal : motionTokens.duration.normal
}, },
@ -240,7 +248,7 @@ export function FadeInCard({ children, delay = 0 }: FadeInCardProps) {
const safeMotion = useSafeMotion(motionTokens.distance.md) const safeMotion = useSafeMotion(motionTokens.distance.md)
// Device gate — skip animation on low-end hardware // Device gate — skip animation on low-end hardware
if (!motionConfig.shouldAnimate || !mounted) { if (!motionConfig.shouldAnimate() || !mounted) {
return <div>{children}</div> return <div>{children}</div>
} }

View File

@ -141,7 +141,7 @@ const item = {
```tsx ```tsx
"use client" "use client"
import { motion, AnimatePresence } from "motion/react" import { motion, AnimatePresence } from "motion/react"
import { springs } from "@/lib/motion-tokens" import { motionTokens, springs } from "@/lib/motion-tokens"
// Wrap at the call site: // Wrap at the call site:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence> // <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>
@ -164,9 +164,17 @@ export function Modal({ onClose }: { onClose: () => void }) {
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
className="fixed inset-x-4 top-1/2 -translate-y-1/2 rounded-xl bg-white p-6" className="fixed inset-x-4 top-1/2 -translate-y-1/2 rounded-xl bg-white p-6"
initial={{ opacity: 0, scale: 0.95, y: 8 }} initial={{
opacity: 0,
scale: motionTokens.scale.press,
y: motionTokens.distance.sm,
}}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 8 }} exit={{
opacity: 0,
scale: motionTokens.scale.press,
y: motionTokens.distance.sm,
}}
transition={springs.gentle} transition={springs.gentle}
/> />
</> </>
@ -179,16 +187,24 @@ export function Modal({ onClose }: { onClose: () => void }) {
```tsx ```tsx
"use client" "use client"
import { motion, AnimatePresence } from "motion/react" import { motion, AnimatePresence } from "motion/react"
import { springs } from "@/lib/motion-tokens" import { motionTokens, springs } from "@/lib/motion-tokens"
<AnimatePresence mode="sync"> <AnimatePresence mode="sync">
{toasts.map((t) => ( {toasts.map((t) => (
<motion.div <motion.div
key={t.id} key={t.id}
layout layout
initial={{ opacity: 0, x: 48, scale: 0.9 }} initial={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
animate={{ opacity: 1, x: 0, scale: 1 }} animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 48, scale: 0.9 }} exit={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
transition={springs.snappy} transition={springs.snappy}
/> />
))} ))}