Bewegungsmuster (Motion Patterns)
Copy-Paste-Muster für die häufigsten UI-Animationen.
Jedes Muster hier basiert auf den motion-foundations-Tokens und Federn.
Definieren Sie hier keine neuen Dauern oder Easing-Werte – importieren Sie sie.
Wann aktivieren
- Animieren eines Buttons, einer Karte, eines Modals oder einer Toast-Benachrichtigung
- Erstellen von Listeneingängen mit Staffelung (Stagger)
- Einrichten von Seitenübergängen im Next.js App Router
- Hinzufügen von Ein- und Ausgangsanimationen zu bedingten Inhalten
- Implementieren von Scroll-Reveal, scrollgebundenem Fortschritt oder klebrigen Story-Abschnitten
- Erstellen von erweiterbaren Karten, Akkordeons oder Shared-Element-Übergängen
Ausgaben
Diese Skill produziert:
- Barrierefreie, SSR-sichere Animationen für alle Standard-UI-Komponenten
AnimatePresence-umwickelte bedingte Render mit korrektem Ausgangsverhalten- Seitenübergangs-Wrapper-Komponente für den Next.js App Router
- Scroll-Reveal und scrollgebundene Muster mit
useScroll+useTransform - Layout-Animationsmuster (
layout,layoutId) zum Erweitern und Überblenden von Elementen
Prinzipien
- Jedes Muster importiert aus
motion-foundations. Keine rohen Zahlen. - Jeder bedingte Render wird in
AnimatePresencemit einemkeyumwickelt. - Ausgangsanimationen werden immer zusammen mit Eingangsanimationen definiert – niemals als nachträglicher Einfall.
layoutwird nur für kleine, isolierte Verschiebungen verwendet. Große Teilbäume erhalten explizite Transformationen.
Regeln
- Umwickeln Sie bedingte Render immer mit
AnimatePresenceund einemkeyauf dem direkten Kind. Ohne Schlüssel werden Ausgangsanimationen niemals ausgelöst. - Definieren Sie immer
exit, wenn Sieinitial+animatedefinieren. Eine Animation ohne Ausgang ist unvollständig. - Verwenden Sie
mode="wait"bei Seitenübergängen. Der Eingang darf nicht beginnen, bevor der Ausgang abgeschlossen ist. - Verwenden Sie
layoutniemals in Teilbäumen mit mehr als ~5 Kindern oder tief verschachteltem DOM. Verwenden Sie stattdessen explizitex/y-Transformationen. - Das Staffelungsintervall muss zwischen
0,05sund0,10sliegen. Darunter wirkt es mechanisch; darüber träge. - Modals müssen immer enthalten: Fokusfalle, Escape-Taste zum Schließen, Scroll-Sperre,
role="dialog",aria-modal="true". - Scroll-Reveals verwenden
viewport={{ once: true }}. Wiederholungen beim Verlassen des Bildschirms lenken ab, informieren nicht. - Alle Token-Werte werden aus
motion-foundationsimportiert. Keine Inline-Zahlen.
Entscheidungshilfe
Das richtige Muster wählen
| Situation | Muster |
|---|---|
| Element erscheint/verschwindet | AnimatePresence |
| Liste von Elementen laden nacheinander | Stagger variants |
| Navigation zwischen Routen | Seitenübergangs-Wrapper |
| Element ändert die Größe an Ort und Stelle | layout prop |
| Gleiches Element bewegt sich über Seitenkontexte | layoutId |
| Element tritt ein, wenn es in den Viewport gescrollt wird | whileInView |
| Wert an die Scrollposition gebunden | useScroll + useTransform |
Wann mode="wait" vs. mode="sync" verwenden
| Modus | Verwenden, wenn |
|---|---|
wait | Seitenübergänge, Inhaltswechsel (einer nach dem anderen) |
sync | Gestapelte Benachrichtigungen, Listenelemente (Überlappung ist in Ordnung) |
popLayout | Elemente aus einer umfließenden Liste entfernt |
Kernkonzepte
AnimatePresence-Vertrag
Drei Dinge müssen immer wahr sein:
AnimatePresenceumwickelt den Bedingungsausdruck- Das direkte Kind hat einen
key - Das Kind hat eine
exit-Eigenschaft
Fehlt eines davon, schlägt die Ausgangsanimation still.
layout vs. layoutId
layout– animiert die eigene Größen-/Positionsänderung des Elements an Ort und StellelayoutId– verbindet zwei separate Elemente und überblendet sie zwischen den Rendervorgängen
Verwenden Sie layout="position" für Text in einem expandierenden Container, um zu verhindern, dass Text-Umfließen animiert wird.
Code-Beispiele
Button-Feedback
"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}
/>
Staffelliste (Stagger)
"use client"
import { motion } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
const container = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.08, // innerhalb der 0,05–0,10 Regel
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>
Modal
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
// Am Aufrufort umwickeln:
// <AnimatePresence>{isOpen && <Modal key="modal" />}</AnimatePresence>
export function Modal({ onClose }: { onClose: () => void }) {
return (
<>
{/* Overlay */}
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Panel — Barrierefreiheitsanforderungen: Fokusfalle, Escape schließen,
Scroll-Sperre, 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}
/>
</>
)
}
Toast-Stapel
"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>
Seitenübergang (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>
)
}
Scroll-Reveal
"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 — Regel 7
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>
Scroll-Fortschrittsbalken
"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 }}
/>
)
}
Erweiterbare Karte
"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" verhindert, dass Text-Umfließen animiert wird */}
<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>
)
}
Shared-Element-Überblendung
// Quellkontext
<motion.img layoutId="hero-image" src={src} className="w-16 h-16 rounded" />
// Zielkontext (gleiches layoutId — motion übernimmt den Übergang)
<motion.img layoutId="hero-image" src={src} className="w-full rounded-xl" />
Akkordeon
<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>
End-to-End-Beispiel
Eine staffellierte Liste, die beim Mounten erscheint, bedingte Präsenz handhabt und
reduzierte Bewegung respektiert – und dabei Tokens, Federn, AnimatePresence und
den Barrierefreiheits-Hook aus motion-foundations kombiniert:
"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}>Entfernen</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>
)
}
Einschränkungen / Nicht-Ziele
Diese Skill deckt nicht ab:
- Token- und Federdefinitionen → siehe
motion-foundations - Zieh-Interaktionen, Wischgesten, neu anordenbare Listen → siehe
motion-advanced - Textanimationen (Wort-/Zeichen-Enthüllungen, Zähler) → siehe
motion-advanced - SVG-Pfadzeichnen oder Morphing → siehe
motion-advanced - Benutzerdefinierte Animations-Hooks → siehe
motion-advanced - Nur-CSS-Übergänge ohne
motion/react
Anti-Patterns
| Anti-Pattern | Verstoßene Regel | Behebung |
|---|---|---|
AnimatePresence-Kind ohne key | Regel 1 | Stabiles key zum direkten Kind hinzufügen |
initial + animate ohne exit | Regel 2 | Alle drei immer zusammen definieren |
Seitenübergang ohne mode="wait" | Regel 3 | mode="wait" zu AnimatePresence hinzufügen |
layout auf einer 50-Elemente-Liste | Regel 4 | mode="popLayout" oder explizite Transformationen verwenden |
staggerChildren: 0.2 auf einer 10-Elemente-Liste | Regel 5 | Auf 0,08–0,10 begrenzen |
| Modal ohne Fokusfalle | Regel 6 | focus-trap-react oder Radix Dialog hinzufügen |
whileInView ohne viewport={{ once: true }} | Regel 7 | Wiederholte Eingänge lenken ab, informieren nicht |
transition={{ duration: 0.3 }} inline | Regel 8 | motionTokens.duration.normal verwenden |
Verwandte Skills
motion-foundations— definiert alle Tokens, Federn, denuseSafeMotion-Hook und SSR-Sicherungen, die jedes Muster hier importiert. Muss zuerst eingerichtet werden.motion-advanced— erweitert diese Muster um Ziehen, Gesten, SVG, Text, benutzerdefinierte Hooks und imperative Sequenzierung. Definiert keine Muster aus dieser Skill neu.