Motion Patterns
Motifs copier-coller pour les besoins d'animation UI les plus courants.
Chaque motif est construit sur les tokens et ressorts de motion-foundations.
Ne définissez pas de nouvelles durées ou courbes d'atténuation ici — importez-les.
Quand activer
- Animation d'un bouton, d'une carte, d'une modale ou d'une notification toast
- Construction d'entrées de liste avec décalage (stagger)
- Mise en place de transitions de page dans Next.js App Router
- Ajout d'animations d'entrée ou de sortie à du contenu conditionnel
- Implémentation de révélations au défilement, de progression liée au défilement ou de sections d'histoire collantes
- Construction de cartes extensibles, d'accordéons ou de transitions d'éléments partagés
Productions
Cette compétence produit :
- Des animations accessibles et compatibles SSR pour tous les composants d'interface standard
- Des rendus conditionnels enveloppés dans
AnimatePresenceavec un comportement de sortie correct - Un composant wrapper de transition de page pour Next.js App Router
- Des motifs de révélation au défilement et de progression liée au défilement utilisant
useScroll+useTransform - Des motifs d'animation de mise en page (
layout,layoutId) pour les éléments expansibles et les fondus enchaînés
Principes
- Chaque motif importe depuis
motion-foundations. Pas de nombres bruts. - Chaque rendu conditionnel est enveloppé dans
AnimatePresenceavec unekey. - Les animations de sortie sont toujours définies en même temps que les animations d'entrée — jamais après coup.
layoutest utilisé uniquement pour de petits déplacements isolés. Les grandes sous-arbres reçoivent des transformations explicites.
Règles
- Enveloppez toujours les rendus conditionnels dans
AnimatePresenceavec unekeysur l'enfant direct. Sans clé, les animations de sortie ne se déclenchent jamais. - Définissez toujours
exitlorsque vous définissezinitial+animate. Une animation sans sortie est incomplète. - Utilisez
mode="wait"sur les transitions de page. L'entrée ne doit pas commencer tant que la sortie n'est pas terminée. - N'utilisez jamais
layoutsur des sous-arbres avec plus de ~5 enfants ou un DOM profondément imbriqué. Utilisez plutôt des transformationsx/yexplicites. - L'intervalle de décalage doit rester entre
0.05set0.10s. En dessous, c'est mécanique ; au-dessus, c'est lent. - Les modales doivent toujours inclure : piège de focus, fermeture par touche Échap, verrouillage du défilement,
role="dialog",aria-modal="true". - Les révélations au défilement utilisent
viewport={{ once: true }}. La répétition lors du défilement vers le haut est distrayante, pas informative. - Toutes les valeurs de token sont importées depuis
motion-foundations. Pas de nombres en ligne.
Guide de décision
Choisir le bon motif
| Situation | Motif |
|---|---|
| Un élément apparaît / disparaît | AnimatePresence |
| Une liste d'éléments chargés en séquence | Variantes de décalage (stagger) |
| Navigation entre les routes | Wrapper de transition de page |
| Un élément change de taille sur place | Propriété layout |
| Même élément se déplace dans différents contextes de page | layoutId |
| Un élément entre lorsqu'il est visible lors du défilement | whileInView |
| Valeur liée à la position de défilement | useScroll + useTransform |
Quand utiliser mode="wait" vs mode="sync"
| Mode | Utilisation |
|---|---|
wait | Transitions de page, échanges de contenu (un à la fois) |
sync | Notifications empilées, éléments de liste (le chevauchement est acceptable) |
popLayout | Éléments retirés d'une liste avec réorganisation |
Concepts clés
Contrat AnimatePresence
Trois choses doivent toujours être vraies :
AnimatePresenceenveloppe le conditionnel- L'enfant direct a une
key - L'enfant a une propriété
exit
Si l'une de ces conditions manque, l'animation de sortie échoue silencieusement.
layout vs layoutId
layout— anime le changement de taille/position propre de l'élément sur placelayoutId— relie deux éléments distincts, en créant un fondu enchaîné entre les rendus
Utilisez layout="position" sur le texte à l'intérieur d'un conteneur extensible pour empêcher l'animation du réécoulement du texte.
Exemples de code
Retour visuel de bouton
"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
<motion.button
whileHover={{ scale: motionTokens.scale.pop }}
whileTap={{ scale: motionTokens.scale.press }}
transition={springs.snappy}
/>
Liste à décalage
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
const container = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08, // dans la règle 0.05–0.10
delayChildren: 0.1,
},
},
}
const item = {
hidden: { opacity: 0, y: motionTokens.distance.md },
visible: { opacity: 1, y: 0, transition: springs.gentle },
}
<motion.ul variants={container} initial="hidden" animate="visible">
{items.map((i) => (
<motion.li key={i.id} variants={item} />
))}
</motion.ul>
Modale
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
// Encapsuler au site d'appel :
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>
export function Modal({ onClose }: { onClose: () => void }) {
return (
<>
{/* Fond */}
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Panneau — exigences d'accessibilité : piège de focus, fermeture Échap,
verrouillage du défilement, role="dialog", aria-modal="true" */}
<motion.div
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: 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}
/>
</>
)
}
Pile de toasts
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
<AnimatePresence mode="sync">
{toasts.map((t) => (
<motion.div
key={t.id}
layout
initial={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{
opacity: 0,
x: motionTokens.distance.xl,
scale: motionTokens.scale.subtle,
}}
transition={springs.snappy}
/>
))}
</AnimatePresence>
Transition de page (Next.js App Router)
// components/page-transition.tsx
"use client"
import { motion, AnimatePresence } from "motion/react"
import { usePathname } from "next/navigation"
import { motionTokens } from "@/lib/motion-tokens"
const variants = {
initial: { opacity: 0, y: motionTokens.distance.sm },
enter: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -motionTokens.distance.sm },
}
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
variants={variants}
initial="initial"
animate="enter"
exit="exit"
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth,
}}
>
{children}
</motion.div>
</AnimatePresence>
)
}
Révélation au défilement
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
<motion.div
initial={{ opacity: 0, y: motionTokens.distance.lg }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }} // once: true — règle 7
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>
Barre de progression au défilement
"use client"
import { motion, useScroll } from "motion/react"
export function ScrollProgress() {
const { scrollYProgress } = useScroll()
return (
<motion.div
className="fixed top-0 left-0 h-1 bg-indigo-500 origin-left w-full"
style={{ scaleX: scrollYProgress }}
/>
)
}
Carte extensible
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
export function ExpandingCard({ title, body }: { title: string; body: string }) {
const [expanded, setExpanded] = useState(false)
return (
<motion.div layout onClick={() => setExpanded(!expanded)} className="cursor-pointer">
{/* layout="position" empêche le réécoulement du texte de s'animer */}
<motion.h2 layout="position" className="font-semibold">
{title}
</motion.h2>
<AnimatePresence>
{expanded && (
<motion.p
key="body"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: motionTokens.duration.fast }}
>
{body}
</motion.p>
)}
</AnimatePresence>
</motion.div>
)
}
Fondu enchaîné d'élément partagé
// Contexte source
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />
// Contexte de destination (même layoutId — motion gère la transition)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />
Accordéon
<motion.div
initial={false}
animate={{ opacity: open ? 1 : 0, scaleY: open ? 1 : 0 }}
style={{ transformOrigin: "top", overflow: "hidden" }}
transition={{
duration: motionTokens.duration.normal,
ease: motionTokens.easing.smooth,
}}
> {children}
</motion.div>
Exemple complet
Une liste à décalage qui entre au montage, gère la présence conditionnelle et
respecte la réduction de mouvement — combinant tokens, ressorts, AnimatePresence et
le hook d'accessibilité de motion-foundations :
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
import { useSafeMotion } from "@/hooks/use-reduced-motion"
const containerVariants = {
hidden: {},
visible: {
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
},
}
function ListItem({ label, onRemove }: { label: string; onRemove: () => void }) {
const safe = useSafeMotion(motionTokens.distance.sm)
return (
<motion.li
variants={{
hidden: safe.initial,
visible: safe.animate,
}}
exit={safe.exit}
transition={springs.gentle}
className="flex items-center justify-between p-3 rounded-lg bg-white shadow-sm"
>
<span>{label}</span>
<button onClick={onRemove}>Supprimer</button>
</motion.li>
)
}
export function AnimatedList({ items, onRemove }: {
items: { id: string; label: string }[]
onRemove: (id: string) => void
}) {
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
className="space-y-2"
>
<AnimatePresence mode="popLayout">
{items.map((item) => (
<ListItem
key={item.id}
label={item.label}
onRemove={() => onRemove(item.id)}
/>
))}
</AnimatePresence>
</motion.ul>
)
}
Contraintes / Non-objectifs
Cette compétence ne couvre pas :
- Les définitions de tokens et de ressorts → voir
motion-foundations - Les interactions de glisser, les gestes de balayage, les listes réorganisables → voir
motion-advanced - Les animations de texte (révélation mot/caractère, compteurs) → voir
motion-advanced - Le dessin ou le morphing de chemins SVG → voir
motion-advanced - Les hooks d'animation personnalisés → voir
motion-advanced - Les transitions CSS uniquement sans utiliser
motion/react
Anti-patrons
| Anti-patron | Règle violée | Correction |
|---|---|---|
Enfant de AnimatePresence sans key | Règle 1 | Ajouter une key stable à l'enfant direct |
initial + animate sans exit | Règle 2 | Toujours définir les trois ensemble |
Transition de page sans mode="wait" | Règle 3 | Ajouter mode="wait" à AnimatePresence |
layout sur une liste de 50 éléments | Règle 4 | Utiliser mode="popLayout" ou des transformations explicites |
staggerChildren: 0.2 sur une liste de 10 éléments | Règle 5 | Limiter à 0.08–0.10 |
| Modale sans piège de focus | Règle 6 | Ajouter focus-trap-react ou Radix Dialog |
whileInView sans viewport={{ once: true }} | Règle 7 | Les entrées répétées distraient, n'informent pas |
transition={{ duration: 0.3 }} en ligne | Règle 8 | Utiliser motionTokens.duration.normal |
Compétences connexes
motion-foundations— définit tous les tokens, ressorts, le hookuseSafeMotionet les gardes SSR que chaque motif importe. Doit être configuré en premier.motion-advanced— étend ces motifs avec le glisser, les gestes, SVG, le texte, les hooks personnalisés et l'ordonnancement impératif. Ne redéfinit aucun motif de cette compétence.