Une animation bien dosée fait passer un site de « correct » à « soigné ». Mal utilisée, elle plombe le bundle, casse le rendu serveur et donne le mal de mer aux utilisateurs sensibles au mouvement. Entre les deux, il y a une poignée de patterns simples.
Dans cet article, je détaille comment intégrer Motion (l'ancien Framer Motion) dans un projet Next.js 16 avec l'App Router : où placer la frontière "use client", quelles propriétés animer pour rester à 60 fps, comment gérer les transitions de page, et comment respecter prefers-reduced-motion sans y penser à chaque composant.
Motion, Framer Motion, CSS : que choisir
Depuis le renommage de Framer Motion en Motion, le package s'installe sous le nom motion et l'import React se fait via motion/react. L'API reste la même, le projet est simplement devenu indépendant de Framer.
npm install motion
Avant d'installer quoi que ce soit, une question mérite d'être posée : est-ce que du CSS suffit ? Pour un hover, un focus, un fade au chargement ou une transition de couleur, la réponse est presque toujours oui. Une transition CSS coûte zéro kilooctet de JavaScript et tourne sur le compositeur du navigateur.
Motion devient utile quand vous avez besoin de :
- animations de sortie (un élément qui disparaît proprement du DOM) — impossible en CSS pur ;
- orchestration (déclencher des enfants en cascade avec un décalage) ;
- gestes (drag, spring réagissant à la vélocité) ;
- layout animations (un élément qui change de position et se déplace tout seul).
Le reste, laissez-le à Tailwind et aux transitions natives. C'est le premier réflexe qui garde un site rapide.
La frontière client : le piège classique
Dans l'App Router, vos pages sont des Server Components par défaut. Motion, lui, a besoin du DOM : tout composant utilisant motion.div doit être un Client Component. Le réflexe malheureux consiste à coller "use client" en haut de page.tsx — et à basculer toute la page côté client, y compris le contenu qui n'avait aucune raison de l'être.
La bonne approche : isoler l'animation dans un composant client minuscule, et lui passer le contenu serveur en children.
// components/fade-in.tsx
'use client';
import { motion } from 'motion/react';
import type { ReactNode } from 'react';
type FadeInProps = {
children: ReactNode;
delay?: number;
};
export function FadeIn({ children, delay = 0 }: FadeInProps) {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.5, delay, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
);
}
Ce composant ne connaît rien de votre contenu. Il enveloppe, il anime, c'est tout :
// app/page.tsx — reste un Server Component
import { FadeIn } from '@/components/fade-in';
import { getArticles } from '@/lib/articles';
export default async function HomePage() {
const articles = await getArticles();
return (
<main>
<FadeIn>
<h1 className="text-4xl font-bold">Bienvenue</h1>
</FadeIn>
{articles.map((article, index) => (
<FadeIn key={article.slug} delay={index * 0.08}>
<ArticleCard article={article} />
</FadeIn>
))}
</main>
);
}
ArticleCard reste un Server Component. Il est rendu sur le serveur, sérialisé, puis passé en children au wrapper client. Seule la logique d'animation part dans le bundle navigateur. Si cette distinction vous paraît floue, j'ai détaillé le sujet dans mon article sur les Server Components vs Client Components.
Réduire le poids avec LazyMotion
Le bundle complet de Motion pèse une trentaine de kilooctets. Sur un site vitrine qui n'a besoin que d'animations simples, LazyMotion permet de ne charger qu'un sous-ensemble de fonctionnalités, en chargeant les features de façon asynchrone.
'use client';
import { LazyMotion, domAnimation, m } from 'motion/react';
import type { ReactNode } from 'react';
export function MotionProvider({ children }: { children: ReactNode }) {
return (
<LazyMotion features={domAnimation} strict>
{children}
</LazyMotion>
);
}
Deux détails comptent ici. On utilise le composant m au lieu de motion (c'est la version allégée, sans les features embarquées). Et le flag strict fait échouer le build si un motion.div traîne quelque part — un garde-fou utile en équipe. Avec domAnimation, on descend autour de 15 ko ; domMax ajoute le drag et les layout animations si vous en avez besoin.
Les animations qui ne coûtent rien (et les autres)
Une animation fluide, c'est une animation que le navigateur peut déléguer au GPU sans recalculer la mise en page. Concrètement, deux propriétés sont gratuites : transform (translate, scale, rotate) et opacity. Tout le reste — width, height, top, margin — déclenche un layout à chaque frame, ce qui s'écroule dès qu'il y a plusieurs éléments animés.
// Coûteux : recalcul du layout à chaque frame
<motion.div animate={{ width: 320, marginLeft: 40 }} />
// Fluide : composité par le GPU
<motion.div animate={{ scaleX: 1.4, x: 40 }} />
Il y a une exception importante : les layout animations. Quand un élément doit changer de taille ou de position pour de vrai, la prop layout de Motion mesure l'avant/après et joue la différence en transform. Vous obtenez le résultat visuel d'une animation de layout, au coût d'une animation de transform.
'use client';
import { motion, AnimatePresence } from 'motion/react';
import { useState } from 'react';
export function Accordion({ title, content }: { title: string; content: string }) {
const [open, setOpen] = useState(false);
return (
<motion.div layout className="rounded-lg border border-slate-200 p-4">
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full justify-between font-medium"
>
{title}
<motion.span animate={{ rotate: open ? 180 : 0 }} aria-hidden="true">
▾
</motion.span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.p
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden text-slate-600"
>
{content}
</motion.p>
)}
</AnimatePresence>
</motion.div>
);
}
AnimatePresence est la vraie valeur ajoutée de Motion : il retarde le démontage du nœud le temps de jouer l'animation exit. C'est ce qu'aucune transition CSS ne sait faire proprement.
Orchestrer plusieurs éléments avec les variants
Répéter des delay calculés à la main devient vite illisible. Les variants permettent de nommer des états et de les propager automatiquement aux enfants.
'use client';
import { motion, type Variants } from 'motion/react';
const container: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.08, delayChildren: 0.15 },
},
};
const item: Variants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.4 } },
};
export function ServiceGrid({ services }: { services: { id: string; name: string }[] }) {
return (
<motion.ul
variants={container}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
className="grid gap-4 sm:grid-cols-3"
>
{services.map((service) => (
<motion.li key={service.id} variants={item} className="rounded-xl bg-white p-6 shadow-sm">
{service.name}
</motion.li>
))}
</motion.ul>
);
}
Le parent déclare initial="hidden" et whileInView="visible" ; les enfants héritent de ces noms d'état sans qu'on ait à les leur passer. staggerChildren gère le décalage. Ajouter une carte ne demande aucun ajustement de timing.
Le paramètre viewport={{ once: true }} mérite qu'on s'y arrête : sans lui, l'animation se rejoue à chaque fois que l'élément repasse dans le viewport. Sur une page longue, c'est fatigant au scroll.
Accessibilité : prefers-reduced-motion n'est pas optionnel
Une part non négligeable des utilisateurs a activé la réduction des animations dans son système — pour des raisons de confort, de troubles vestibulaires ou de migraines. Ignorer ce réglage, c'est rendre un site désagréable, voire inutilisable, pour ces personnes. C'est aussi un critère WCAG (2.3.3).
Motion expose un hook dédié :
'use client';
import { motion, useReducedMotion } from 'motion/react';
import type { ReactNode } from 'react';
export function FadeIn({ children }: { children: ReactNode }) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: shouldReduceMotion ? 0 : 0.5 }}
>
{children}
</motion.div>
);
}
La règle que j'applique : on ne supprime pas l'animation, on supprime le déplacement. Un fondu d'opacité reste confortable et conserve l'indication visuelle qu'un contenu est apparu ; c'est le mouvement de translation ou de scale qui pose problème.
Plus radical et souvent suffisant sur un site vitrine, MotionConfig désactive toutes les animations réduites d'un coup, à la racine :
<MotionConfig reducedMotion="user">{children}</MotionConfig>
Le sujet s'inscrit dans une démarche plus large que j'ai détaillée dans mon article sur l'accessibilité web avec Next.js.
Animations et Core Web Vitals
Trois pièges reviennent systématiquement sur les sites que j'audite.
Animer le contenu au-dessus de la ligne de flottaison. Un titre h1 qui fade-in au chargement retarde mécaniquement le LCP : le navigateur ne considère l'élément peint qu'une fois opaque. Sur un hero, animez le décor, pas le texte principal.
Provoquer du layout shift. Un élément qui arrive avec y: 40 et pousse ses voisins fait grimper le CLS. Les transforms n'affectent pas la mise en page — à condition que l'espace soit déjà réservé côté serveur. Ne conditionnez jamais le rendu du contenu à un useEffect d'animation.
Charger Motion sur toutes les pages. Si seule la page d'accueil est animée, importez le provider dans son layout, pas dans le RootLayout. Ce qui n'est pas envoyé au navigateur ne coûte rien — le principe de base que je rappelle dans mon article sur les Core Web Vitals.
Un ordre de grandeur pour calibrer : 150 à 300 ms pour une micro-interaction (hover, bouton, ouverture de menu), 400 à 600 ms pour une entrée d'élément au scroll. Au-delà, l'utilisateur attend l'interface au lieu de l'utiliser. Et une courbe d'easing comme [0.22, 1, 0.36, 1] — départ rapide, arrivée douce — paraîtra toujours plus naturelle qu'un linear.
Conclusion
Motion dans Next.js 16 tient en quelques principes : garder la frontière client aussi petite que possible en passant le contenu serveur en children, se limiter à transform et opacity sauf à utiliser la prop layout, orchestrer avec des variants plutôt qu'avec des délais codés à la main, et respecter prefers-reduced-motion par défaut plutôt qu'après coup.
Le vrai critère de qualité n'est pas la quantité d'animations, c'est leur discrétion. Une bonne animation se remarque à peine — elle rend juste l'interface plus lisible, en signalant ce qui apparaît, ce qui se déplace, ce qui vient de changer. Si un visiteur vous dit « c'est joliment animé », c'est probablement qu'il y en a trop.
Vous avez un projet de site web et vous voulez une interface soignée qui reste rapide ? N'hésitez pas à me contacter pour en discuter — vous pouvez aussi m'écrire directement à contact@alexis-mouchon.fr.