diff --git a/skills/motion-advanced/SKILL.md b/skills/motion-advanced/SKILL.md
index 3b4116b7..b50aa39c 100644
--- a/skills/motion-advanced/SKILL.md
+++ b/skills/motion-advanced/SKILL.md
@@ -278,7 +278,7 @@ import { motionTokens } from "@/lib/motion-tokens"
d="M 0 100 Q 50 0 100 100"
initial={{ pathLength: 0, opacity: 0 }}
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"
import { useEffect } from "react"
import { motion, useMotionValue, useSpring } from "motion/react"
+import { springs } from "@/lib/motion-tokens"
export function CursorFollower() {
const x = useMotionValue(-100)
const y = useMotionValue(-100)
- const sx = useSpring(x, { stiffness: 120, damping: 16 })
- const sy = useSpring(y, { stiffness: 120, damping: 16 })
+ const sx = useSpring(x, springs.gentle)
+ const sy = useSpring(y, springs.gentle)
useEffect(() => {
const move = (e: MouseEvent) => { x.set(e.clientX); y.set(e.clientY) }
@@ -363,29 +364,44 @@ export function CursorFollower() {
```tsx
"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()
+
useEffect(() => {
- const run = () =>
- controls.start({ x: ["-100%", "100%"], transition: { repeat: Infinity, duration: 1.2, ease: "linear" } })
- const onVis = () => (document.visibilityState === "hidden" ? controls.stop() : run())
- run()
- document.addEventListener("visibilitychange", onVis)
- return () => document.removeEventListener("visibilitychange", onVis)
+ const play = () =>
+ controls.start({
+ x: ["-100%", "100%"],
+ transition: {
+ repeat: Infinity,
+ 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])
- return (
-
-
+
-
- )
- }
+ />
)
}
@@ -443,8 +459,9 @@ export function LoadingButton({
```tsx
"use client"
-import { useEffect, useRef } from "react"
+import { useEffect } from "react"
import { motion, useAnimation } from "motion/react"
+import { motionTokens } from "@/lib/motion-tokens"
export function PulseDot() {
const controls = useAnimation()
@@ -454,19 +471,23 @@ export function PulseDot() {
controls.start({
scale: [1, 1.4, 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
const handleVisibility = () => {
if (document.visibilityState === "hidden") controls.stop()
- else pulse()
+ else void pulse()
}
- pulse()
+ void pulse()
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
}
diff --git a/skills/motion-foundations/SKILL.md b/skills/motion-foundations/SKILL.md
index 94521f1b..e853b83b 100644
--- a/skills/motion-foundations/SKILL.md
+++ b/skills/motion-foundations/SKILL.md
@@ -27,7 +27,7 @@ This skill produces:
- A shared `motionTokens` object (duration, easing, distance, scale)
- 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`
- 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
-Disable (set `shouldAnimate = false`) when:
+Disable (make `shouldAnimate()` return `false`) when:
- `prefersReduced` is `true`
- `isLowEnd` is `true` and the animation is non-essential
@@ -134,20 +134,28 @@ export const springs = {
```ts
// lib/motion-config.ts
export const motionConfig = {
- isLowEnd:
- typeof navigator !== "undefined" &&
- navigator.hardwareConcurrency <= 4,
-
- prefersReduced:
- typeof window !== "undefined" &&
- window.matchMedia("(prefers-reduced-motion: reduce)").matches,
-
- get shouldAnimate() {
- return !this.prefersReduced
+ isLowEnd() {
+ return (
+ typeof navigator !== "undefined" &&
+ navigator.hardwareConcurrency <= 4
+ )
},
- get duration() {
- return this.isLowEnd || this.prefersReduced
+ 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.normal
},
@@ -240,7 +248,7 @@ export function FadeInCard({ children, delay = 0 }: FadeInCardProps) {
const safeMotion = useSafeMotion(motionTokens.distance.md)
// Device gate — skip animation on low-end hardware
- if (!motionConfig.shouldAnimate || !mounted) {
+ if (!motionConfig.shouldAnimate() || !mounted) {
return {children}
}
diff --git a/skills/motion-patterns/SKILL.md b/skills/motion-patterns/SKILL.md
index 80ff2dd9..16425d18 100644
--- a/skills/motion-patterns/SKILL.md
+++ b/skills/motion-patterns/SKILL.md
@@ -141,7 +141,7 @@ const item = {
```tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
-import { springs } from "@/lib/motion-tokens"
+import { motionTokens, springs } from "@/lib/motion-tokens"
// Wrap at the call site:
// {isOpen && }
@@ -164,9 +164,17 @@ export function Modal({ onClose }: { onClose: () => void }) {
role="dialog"
aria-modal="true"
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 }}
- animate={{ opacity: 1, scale: 1, y: 0 }}
- exit={{ 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 }}
+ exit={{
+ opacity: 0,
+ scale: motionTokens.scale.press,
+ y: motionTokens.distance.sm,
+ }}
transition={springs.gentle}
/>
>
@@ -179,16 +187,24 @@ export function Modal({ onClose }: { onClose: () => void }) {
```tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
-import { springs } from "@/lib/motion-tokens"
+import { motionTokens, springs } from "@/lib/motion-tokens"
{toasts.map((t) => (
))}
@@ -317,7 +333,7 @@ export function ExpandingCard({ title, body }: { title: string; body: string })
initial={false}
animate={{ opacity: open ? 1 : 0, scaleY: open ? 1 : 0 }}
style={{ transformOrigin: "top", overflow: "hidden" }}
- transition={{
+ transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth,
}}