Scroll-linked animation in the Next.js App Router
Motion that survives the App Router
Server components cannot hold animation state, so every motion primitive has to sit behind a client boundary. The trick is putting that boundary as low in the tree as possible — wrap the animated element, not the page.
Keep the boundary small
import { motion } from 'framer-motion';
export function FadeIn({ children }) { return ( <motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: '-40px' }} transition={{ duration: 0.5 }} > {children} </motion.div> ); } ```
The page stays a server component; only the wrapper ships JavaScript.
Animate the cheap properties
Stick to `transform` and `opacity`. Both are handled on the compositor, so they never trigger layout or paint. Animating `width`, `height`, `top` or `left` forces a reflow on every frame and will drop frames on mid-range phones.
Respect reduced motion
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}One rule in your global stylesheet covers every animation on the site.