Motion Patterns
Patrons copier-coller pour les besoins d'animation UI les plus courants.
Chaque patron ici est construit sur les jetons et ressorts de motion-foundations.
Ne définissez pas de nouvelles valeurs de durée ou d'assouplissement ici — importez-les.
Quand activer
- Animer un bouton, une carte, une modale ou une notification toast
- Construire des entrées de liste avec effet de décalage (stagger)
- Mettre en place des transitions de page dans le Next.js App Router
- Ajouter des animations d'entrée ou de sortie pour du contenu conditionnel
- Implémenter des révélations au défilement, une progression liée au défilement ou des sections d'histoire collantes
- Construire des cartes extensibles, des accordéons ou des transitions d'éléments partagés
Résultats
Ce skill produit :
- Des animations accessibles et compatibles SSR pour tous les composants d'interface standard
- Des rendus conditionnels enveloppés avec
AnimatePresenceet un comportement de sortie correct - Un composant d'encapsulation de transition de page pour le Next.js App Router
- Des motifs de révélation au défilement et liés au défilement utilisant
useScroll+useTransform - Des motifs d'animation de mise en page (
layout,layoutId) pour les éléments extensibles et les fondus enchaînés
Principes
- Chaque patron importe depuis
motion-foundations. Pas de nombres bruts. - Chaque rendu conditionnel est enveloppé dans
AnimatePresenceavec unekey. - Les animations de sortie sont toujours définies avec 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
exitquand 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 des transformationsx/yexplicites à la place. - L'intervalle de décalage (stagger) doit rester entre
0.05set0.10s. En dessous, c'est mécanique ; au-dessus, c'est lent. - Les modales doivent toujours inclure : piège à 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 l'arrière est distrayante, pas informative. - Toutes les valeurs des jetons sont importées de
motion-foundations. Pas de nombres en ligne.
Guide de décision
Choisir le bon patron
| Situation | Patron |
|---|---|
| Un élément apparaît / disparaît | AnimatePresence |
| Liste d'éléments chargés en séquence | Variantes avec décalage (stagger) |
| Navigation entre les routes | Encapsulateur de transition de page |
| Un élément change de taille sur place | Propriété layout |
| Même élément se déplace entre des contextes de page | layoutId |
| Un élément entre lorsqu'il est visible au défilement | whileInView |
| Valeur liée à la position de défilement | useScroll + useTransform |
Quand utiliser mode="wait" vs mode="sync"
| Mode | Utiliser quand |
|---|---|
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 supprimés d'une liste à réorganisation (reflow) |
Concepts fondamentaux
Contrat AnimatePresence
Trois choses doivent toujours être vraies :
AnimatePresenceencapsule 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 séparés, réalisant un fondu enchaîné entre eux à travers les rendus
Utilisez layout="position" sur le texte à l'intérieur d'un conteneur extensible pour empêcher l'animation du réagencement du texte.
Exemples de code
Feedback 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 avec décalage (stagger)
"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"
// Encapsulez 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 à 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 de 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 l'animation du réagencement du texte */}
<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 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 le mouvement réduit — combinant jetons, 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 / Hors périmètre
Ce skill ne couvre pas :
- Les définitions de jetons et de ressorts → voir
motion-foundations - Les interactions de glisser-déposer, les gestes de glissement, les listes réorganisables → voir
motion-advanced - Les animations de texte (révélation de mots/caractères, 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
motion/react
Anti-patrons
| Anti-patron | Règle violée | Correction |
|---|---|---|
L'enfant de AnimatePresence manque de key | Règle 1 | Ajoutez une key stable à l'enfant direct |
initial + animate sans exit | Règle 2 | Définissez toujours les trois ensemble |
Transition de page sans mode="wait" | Règle 3 | Ajoutez mode="wait" à AnimatePresence |
layout sur une liste de 50 éléments | Règle 4 | Utilisez mode="popLayout" ou des transformations explicites |
staggerChildren: 0.2 sur une liste de 10 éléments | Règle 5 | Limitez à 0.08–0.10 |
| Modale sans piège à focus | Règle 6 | Ajoutez 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 | Utilisez motionTokens.duration.normal |
Compétences associées
motion-foundations— définit tous les jetons, les ressorts, le hookuseSafeMotion, et les gardes SSR que chaque patron ici importe. Doit être configuré en premier.motion-advanced— étend ces patrons avec le glisser-déposer, les gestes, SVG, le texte, les hooks personnalisés et le séquencement impératif. Ne redéfinit aucun patron de cette compétence.