From cb2a70ce7250d9728d83d31b607dc074577a4f6a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 12 May 2026 01:35:35 -0400 Subject: [PATCH] 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. --- skills/motion-advanced/SKILL.md | 75 +++++++++++++++++++----------- skills/motion-foundations/SKILL.md | 38 +++++++++------ skills/motion-patterns/SKILL.md | 34 ++++++++++---- 3 files changed, 96 insertions(+), 51 deletions(-) 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, }}