Que fait Motion Patterns ?
Motifs copier-coller pour les besoins d'animation UI les plus courants.
Chaque motif ici est basé sur les tokens et ressorts de motion-foundations.
Ne définissez pas de nouvelles durées ou courbes d'easing ici — importez-les.
Quand activer ce skill
- 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 Next.js App Router
- Ajouter des animations d'entrée ou de sortie à du contenu conditionnel
- Implémenter des animations de révélation au défilement, une barre de progression liée au défilement ou des sections à effet sticky
- Construire des cartes extensibles, des accordéons ou des transitions d'élément partagé
Résultats
Ce skill produit :
- Des animations accessibles et compatibles SSR pour tous les composants d'interface standard
- Des rendus conditionnels encapsulés dans
AnimatePresenceavec un comportement de sortie correct - Un composant d'encapsulation 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 extensibles et les fondus enchaînés
Principes
- Chaque motif importe depuis
motion-foundations. Pas de valeurs brutes. - Chaque rendu conditionnel est encapsulé 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 utilisent des transformations explicites.
Règles
- Encapsulez 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 avant la fin de la sortie. - N'utilisez jamais
layoutsur des sous-arbres de plus de ~5 enfants ou profondément imbriqués dans le DOM. Utilisez des transformations explicitesx/yà la place. - L'intervalle de stagger doit rester entre
0.05set0.10s. En dessous, l'effet semble mécanique ; au-dessus, il semble 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 au défilement inverse est distrayante, pas informative. - Toutes les valeurs des tokens sont importées depuis
motion-foundations. Pas de nombres en ligne.
Guide de décision
Choisir le bon motif
| Situation | Motif |
|---|---|
| L'élément apparaît / disparaît | AnimatePresence |
| Liste d'éléments chargés en séquence | Variantes de stagger |
| Navigation entre routes | Encapsulation de transition de page |
| L'élément change de taille sur place | Propriété layout |
| Même élément se déplace entre contextes de page | layoutId |
| L'élément apparaît lorsqu'il est visible dans la vue | 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 (les chevauchements sont acceptables) |
popLayout | Éléments retirés d'une liste à réorganisation |
Concepts clés
Contrat AnimatePresence
Trois conditions doivent toujours être réunies :
AnimatePresenceencapsule la partie conditionnelle- 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 de l'élément lui-même sur placelayoutId— relie deux éléments distincts, créant un fondu enchaîné entre eux lors des rendus
Utilisez layout="position" sur le texte à l'intérieur d'un conteneur extensible pour empêcher le réarrangement du texte d'être animé.
Exemples de code
Retour visuel d'un 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 (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"
// Encapsuler au point 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 par É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 notifications toast
"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 du 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éarrangement du texte d'être animé */}
<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 apparaît 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
Ce skill 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 de mots/caractères, compteurs) → voir
motion-advanced - Le dessin ou 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 d'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, ne renseignent pas |
transition={{ duration: 0.3 }} en ligne | Règle 8 | Utiliser motionTokens.duration.normal |
Compétences associées
motion-foundations— définit tous les tokens, ressorts, le hookuseSafeMotionet les gardes SSR que chaque motif importe ici. Doit être configuré en premier.motion-advanced— étend ces motifs avec le glisser, les gestes, SVG, le texte, les hooks personnalisés et le séquencement impératif. Ne redéfinit aucun motif de ce skill.