framer-motion — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited framer-motion (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Production-grade animation patterns for React and Next.js. This skill helps you write correct, performant, accessible animations using the Motion library (v12+).
Motion rebranded from framer-motion to motion. Both package names work, but the import path matters:
// Client Components (standard React)
import { motion, AnimatePresence } from "motion/react"
// Next.js Server Components — use the client export
import * as motion from "motion/react-client"
// Legacy import (still works with the framer-motion package)
import { motion } from "framer-motion"If the project already uses framer-motion as a dependency, keep using "framer-motion" imports for consistency. Don't mix import sources.
Every HTML/SVG element has a motion counterpart. These accept animation props:
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
/>Important: motion.* components are client components. In Next.js App Router, either mark the file "use client" or wrap them in a client component.
useMotionValue creates values that update without triggering React re-renders. This is the key to 60fps animations:
const x = useMotionValue(0)
const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0])
return <motion.div style={{ x, opacity }} drag="x" />Rules:
useMotionValue for any value that changes every frame (scroll position, drag position, continuous animation)useTransform to derive values from other MotionValues (no re-renders)useState for frame-by-frame updates — it causes re-renders on every frameControl how animations move between states:
// Spring (default for physical properties like x, y, scale)
transition={{ type: "spring", stiffness: 300, damping: 30 }}
// Tween (default for non-physical properties like opacity, color)
transition={{ type: "tween", duration: 0.3, ease: "easeInOut" }}
// Custom cubic bezier
transition={{ duration: 0.7, ease: [0.16, 1, 0.3, 1] }}The most common animation pattern. Use whileInView — do NOT manually use IntersectionObserver:
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.1 }}
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
>
{children}
</motion.div>viewport.once: true — only animate on first entry (standard for marketing pages)viewport.amount — how much of the element must be visible (0.1 = 10%)viewport.margin — extend the trigger area (e.g., "0px 0px 50px 0px")Animate children one after another using variants:
const container = {
hidden: {},
show: { transition: { staggerChildren: 0.1 } },
}
const item = {
hidden: { opacity: 0, y: 20 },
show: { opacity: 1, y: 0 },
}
<motion.ul variants={container} initial="hidden" whileInView="show" viewport={{ once: true }}>
{items.map((i) => (
<motion.li key={i} variants={item}>{i}</motion.li>
))}
</motion.ul>Variants propagate — parent state changes flow to children automatically.
Wrap conditionally rendered elements to animate them out before removal:
<AnimatePresence>
{isOpen && (
<motion.div
key="modal"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>Rules:
keyexit prop defines the exit animationmode="wait" to finish exit before entering next elementmode="popLayout" for layout-aware transitionsTie animation directly to scroll position:
const { scrollYProgress } = useScroll()
const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0])
const scale = useTransform(scrollYProgress, [0, 1], [1, 0.8])
return <motion.div style={{ opacity, scale }} />For element-specific scroll tracking:
const ref = useRef(null)
const { scrollYProgress } = useScroll({
target: ref,
offset: ["start end", "end start"], // when element enters/exits viewport
})Use useSpring for buttery-smooth transitions of MotionValues:
const scrollY = useMotionValue(0)
const smoothY = useSpring(scrollY, { stiffness: 100, damping: 30 })For animations that run every frame (gradient shimmer, counting, etc.):
const progress = useMotionValue(0)
useAnimationFrame((time, delta) => {
const newValue = (time / 1000) % 100
progress.set(newValue)
})
const backgroundPosition = useTransform(progress, (p) => `${p}% 50%`)
return <motion.span style={{ backgroundPosition }} />Animate layout changes automatically:
// Simple layout animation
<motion.div layout />
// Shared layout animation between components
<motion.div layoutId="hero-image" />When the same layoutId exists in two different components, Motion animates between them (e.g., thumbnail to full-screen).
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.97 }}
whileFocus={{ outline: "2px solid #5227FF" }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
/><motion.div
drag // enable both axes
drag="x" // constrain to x-axis
dragConstraints={{ left: -100, right: 100 }}
dragElastic={0.2}
onDragEnd={(e, info) => {
if (info.offset.x > 100) handleSwipeRight()
}}
/>Always respect the user's motion preferences. This is not optional — it's an accessibility requirement:
import { useReducedMotion } from "framer-motion"
function AnimatedComponent({ children }) {
const prefersReducedMotion = useReducedMotion()
if (prefersReducedMotion) {
return <div>{children}</div>
}
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
>
{children}
</motion.div>
)
}Pattern: Check useReducedMotion() and either skip animation entirely or reduce it to opacity-only (no movement).
transform properties instead (x, y, scale, rotate).useState causes re-renders on every frame. MotionValues update the DOM directly.filter: drop-shadow() or animate opacity of a pseudo-element shadow instead.| Mistake | Fix |
|---|---|
useState for drag position | useMotionValue |
Manual IntersectionObserver | whileInView prop |
setTimeout for stagger | variants with staggerChildren |
Missing key in AnimatePresence | Add unique key to children |
| Animating in Server Components | Add "use client" or use motion/react-client |
| Ignoring reduced motion | Always check useReducedMotion() |
animate on mount without initial | Set initial to define start state |
const { scrollYProgress } = useScroll()
return (
<motion.div
className="fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50"
style={{ scaleX: scrollYProgress }}
/>
)const count = useMotionValue(0)
const rounded = useTransform(count, (v) => Math.round(v))
useEffect(() => {
const controls = animate(count, target, { duration: 2 })
return controls.stop
}, [target])
return <motion.span>{rounded}</motion.span>// template.tsx
"use client"
import { motion } from "framer-motion"
export default function Template({ children }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
)
}const progress = useMotionValue(0)
useAnimationFrame((time) => {
const duration = 8000
const fullCycle = duration * 2
const cycleTime = (time % fullCycle)
if (cycleTime < duration) {
progress.set((cycleTime / duration) * 100)
} else {
progress.set(100 - ((cycleTime - duration) / duration) * 100)
}
})
const backgroundPosition = useTransform(progress, (p) => `${p}% 50%`)
return (
<motion.span
className="bg-clip-text text-transparent"
style={{
backgroundImage: "linear-gradient(to right, #5227FF, #a78bfa, #c084fc, #5227FF)",
backgroundSize: "300% 100%",
backgroundPosition,
}}
>
{text}
</motion.span>
)~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.