Motion Advanced
Komplexe, interaktive und physikbasierte Animationsmuster.
Erfordert, dass motion-foundations zuerst eingerichtet ist.
Verwende diese, wenn motion-patterns nicht ausreicht.
Wann aktivieren
- Erstellen von Drag-to-Dismiss-Blättern, Wischgesten oder neu anordenbaren Listen
- Animieren von Text Wort für Wort, Zeichen für Zeichen oder als Live-Zähler
- Zeichnen von SVG-Pfaden, Morphen von Symbolen oder Animieren von kreisförmigen Fortschritten
- Schreiben eines benutzerdefinierten Animations-Hooks (
useScrollReveal, magnetischer Button, Cursor-Follower) - Sequenzieren von mehrstufigen Animationen imperativ mit
useAnimate - Erstellen von Spinnern, Schimmer-Skeletten, Puls-Indikatoren oder Ladezuständen für Buttons
Ausgaben
Dieses Skill produziert:
- Drag-Interaktionen: ziehbare Karten, Drag-to-Dismiss-Blätter,
Reorder.Group-Listen - Geste-Hooks: Wischerkennung, langer Druck, Pin-Umriss
- Textanimationskomponenten: Wort-Enthüllung, Zeichen-Schreibmaschine, Zahlenzähler
- SVG-Animation: Pfadzeichnung, Symbolmorphing, Strich-Fortschrittsring
- Benutzerdefinierte Hooks:
useScrollReveal,useHoverScale,useNavigationDirection,useInViewOnce - Imperative Sequenzen via
useAnimatemit unterbrechungssicheremasync/await - Ladekomponenten: Spinner, Schimmer, Puls-Punkt, Fortschrittsbalken, Ladezustand für Buttons
Prinzipien
- Physikbasierte Bewegung (
useSpring,springs.*) fühlt sich bei direkter Manipulation immer natürlicher an als zeitbasierte. useMotionValue+useTransformberechnet abgeleitete Werte, ohne Neurenderungen auszulösen.useAnimate-Sequenzen sind imperativ und unterbrechungssicher – ein Aufruf vonanimate()während der Ausführung bricht die vorherige Animation automatisch ab.- Motion Values (
useMotionValue,useSpring) sind SSR-sicher und verursachen keine Hydrierungsfehler.
Regeln
- Drag-Interaktionen müssen auf Touch-Geräten getestet werden, nicht nur mit der Maus. Die
drag-Eigenschaft funktioniert auf beiden, aber das Gefühl und die Schwellenwerte unterscheiden sich. - Unendliche Animationen müssen pausieren, wenn
document.visibilityState === "hidden". Hintergrund-Tabs dürfen keine GPU/CPU verbrauchen. - Der Wisch-Schwellenwert muss explizit sein. Niemals die Absicht nur aus der Geschwindigkeit ableiten; kombiniere
offset- undvelocity-Prüfungen. - Der
useAnimate-Bereichs-Ref muss an ein gemountetes DOM-Element angehängt werden. Ein Aufruf vonanimate()vor dem Mounten wirft still einen Fehler. - Motion Values dürfen nicht bei jedem Rendern neu erstellt werden.
useMotionValue(0)innerhalb eines Komponentenrumpfs ist korrekt;new MotionValue(0)in einem Render ist nicht. - Alle Token-Werte werden aus
motion-foundationsimportiert. Keine Inline-Zahlen. - Benutzerdefinierte Hooks müssen die Bereinigung durchführen. Jeder
window.addEventListenerbenötigt einen passendenremoveEventListenerimuseEffect-Rückgabewert. - SVG-Morphing erfordert gleiche Pfadkommando-Anzahlen. Pfade mit unterschiedlichen Kommandostrukturen springen statt zu interpolieren.
Entscheidungshilfe
Die richtige erweiterte API wählen
| Szenario | API |
|---|---|
| Drag mit Physik beim Loslassen | drag + dragTransition: springs.release |
| Geordnete Drag-to-Reorder-Liste | Reorder.Group + Reorder.Item |
| Schließen bei Drag-Offset | drag="y" + onDragEnd-Offset-Prüfung |
| Nach links/rechts wischen | drag="x" + onDragEnd-Offset-Prüfung |
| Langer Druck | useLongPress-Hook |
| Über Zeit geglätteter Wert | useSpring |
| Von einem anderen abgeleiteter Wert | useTransform |
| Mehrstufige Sequenz | useAnimate mit async/await |
| Einmalige imperative Animation | animate() aus motion |
| Text, der Wort für Wort erscheint | Stagger auf inline-block-Spans |
| SVG-Zeichnung | pathLength 0 → 1 |
| SVG-Morph | d-Attribut-Tween (gleiche Kommandos) |
| Kreisförmiger Fortschritt | strokeDashoffset-Tween |
Wann useSpring vs. eine Spring-Transition verwenden
useSpring | transition: springs.* | |
|---|---|---|
| Verwende für | Cursor-Follower, zeigerverfolgte Werte | Diskrete Zustandsänderungen |
| Aktualisierungen | Kontinuierlich, bei jedem Frame | Ausgelöst durch Zustandsänderung |
| Unterbrechung | Sanft – Physik übernimmt von der Geschwindigkeit | Startet vom aktuellen Wert neu |
Kernkonzepte
useMotionValue + useTransform
Reaktive Berechnung ohne Neurenderungen:
const x = useMotionValue(0)
const opacity = useTransform(x, [-200, 0, 200], [0, 1, 0])
// opacity wird bei jeder Änderung von x aktualisiert – kein setState, kein Neurendern
useAnimate
Gibt [scope, animate] zurück. Der Bereichs-Ref muss an ein DOM-Element angehängt werden.
animate()-Aufrufe sind unterbrechungssicher – ein Aufruf während der Ausführung bricht den vorherigen Durchlauf ab.
const [scope, animate] = useAnimate()
async function play() {
await animate(".step-1", { opacity: 1 }, { duration: 0.3 })
await animate(".step-2", { x: 0 }, { duration: 0.4 })
animate(".step-3", { scale: 1 }, { duration: 0.25 }) // Feuer-und-Vergiss
}
return <div ref={scope}>...</div>
Codebeispiele
Ziehbare Karte
"use client"
import { motion } from "motion/react"
import { springs, motionTokens } from "@/lib/motion-tokens"
<motion.div
drag
dragConstraints={{ left: -100, right: 100, top: -100, bottom: 100 }}
dragElastic={0.1}
whileDrag={{
scale: motionTokens.scale.pop,
boxShadow: "0 16px 40px rgba(0,0,0,0.2)",
}}
dragTransition={springs.release}
/>
Drag-to-Dismiss-Blatt
"use client"
import { motion, useMotionValue, useTransform } from "motion/react"
export function BottomSheet({ onClose }: { onClose: () => void }) {
const y = useMotionValue(0)
const opacity = useTransform(y, [0, 200], [1, 0])
return (
<motion.div
drag="y"
dragConstraints={{ top: 0 }}
style={{ y, opacity }}
onDragEnd={(_, info) => {
// Regel 3: Offset + Geschwindigkeit kombinieren
if (info.offset.y > 120 || info.velocity.y > 500) onClose()
}}
/>
)
}
Neu anordenbare Liste
"use client"
import { Reorder } from "motion/react"
export function SortableList() {
const [items, setItems] = useState(initialItems)
return (
<Reorder.Group axis="y" values={items} onReorder={setItems}>
{items.map((item) => (
<Reorder.Item key={item.id} value={item}>
{item.label}
</Reorder.Item>
))}
</Reorder.Group>
)
}
Wischerkennung
"use client"
import { motion } from "motion/react"
const OFFSET_THRESHOLD = 50
const VELOCITY_THRESHOLD = 300
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 0 }}
onDragEnd={(_, info) => {
const swipedRight = info.offset.x > OFFSET_THRESHOLD || info.velocity.x > VELOCITY_THRESHOLD
const swipedLeft = info.offset.x < -OFFSET_THRESHOLD || info.velocity.x < -VELOCITY_THRESHOLD
if (swipedRight) onSwipeRight()
if (swipedLeft) onSwipeLeft()
}}
/>
Langer-Druck-Hook
import { useRef } from "react"
export function useLongPress(callback: () => void, ms = 600) {
const timerRef = useRef<ReturnType<typeof setTimeout>>()
return {
onPointerDown: () => { timerRef.current = setTimeout(callback, ms) },
onPointerUp: () => clearTimeout(timerRef.current),
onPointerLeave: () => clearTimeout(timerRef.current),
}
}
Wort-für-Wort-Enthüllung
"use client"
import { motion } from "motion/react"
import { springs } from "@/lib/motion-tokens"
export function AnimatedText({ text }: { text: string }) {
return (
<motion.p
variants={{ visible: { transition: { staggerChildren: 0.05 } } }}
initial="hidden"
animate="visible"
>
{text.split(" ").map((word, i) => (
<motion.span
key={i}
className="inline-block mr-1"
variants={{
hidden: { opacity: 0, y: 12 },
visible: { opacity: 1, y: 0, transition: springs.gentle },
}}
>
{word}
</motion.span>
))}
</motion.p>
)
}
Zahlenzähler
"use client"
import { useRef, useEffect } from "react"
import { animate } from "motion"
import { motionTokens } from "@/lib/motion-tokens"
export function Counter({ to }: { to: number }) {
const nodeRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
const controls = animate(0, to, {
duration: motionTokens.duration.crawl,
ease: motionTokens.easing.smooth,
onUpdate: (v) => {
if (nodeRef.current) nodeRef.current.textContent = Math.round(v).toString()
},
})
return controls.stop // Regel 7: Bereinigung
}, [to])
return <span ref={nodeRef} />
}
SVG-Pfadzeichnung
"use client"
import { motion } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
<motion.path
d="M 0 100 Q 50 0 100 100"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: motionTokens.duration.slow, ease: motionTokens.easing.smooth }}
/>
Strich-Fortschrittsring
"use client"
import { motion } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
const CIRCUMFERENCE = 2 * Math.PI * 40 // r=40
export function ProgressRing({ progress }: { progress: number }) {
return (
<svg width="100" height="100" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="none" stroke="#e5e7eb" strokeWidth="8" />
<motion.circle
cx="50" cy="50" r="40"
fill="none" stroke="#6366f1" strokeWidth="8"
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
animate={{ strokeDashoffset: CIRCUMFERENCE - (progress / 100) * CIRCUMFERENCE }}
transition={{ duration: motionTokens.duration.normal, ease: motionTokens.easing.smooth }}
style={{ rotate: -90, transformOrigin: "center" }}
/>
</svg>
)
}
useScrollReveal-Hook
"use client"
import { useRef } from "react"
import { useScroll, useTransform } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
export function useScrollReveal() {
const ref = useRef(null)
const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"] })
const opacity = useTransform(scrollYProgress, [0, 0.3], [0, 1])
const y = useTransform(scrollYProgress, [0, 0.3], [motionTokens.distance.lg, 0])
return { ref, style: { opacity, y } }
}
// Verwendung
const { ref, style } = useScrollReveal()
<motion.section ref={ref} style={style} />
Cursor-Follower
"use client"
import { useEffect } from "react"
import { motion, useMotionValue, useSpring } from "motion/react"
import { springs } from "@/lib/motion-tokens"
export function CursorFollower() {
const x = useMotionValue(-100)
const y = useMotionValue(-100)
const sx = useSpring(x, springs.gentle)
const sy = useSpring(y, springs.gentle)
useEffect(() => {
const move = (e: MouseEvent) => { x.set(e.clientX); y.set(e.clientY) }
window.addEventListener("mousemove", move)
return () => window.removeEventListener("mousemove", move) // Regel 7
}, [])
return (
<motion.div
className="fixed top-0 left-0 w-6 h-6 rounded-full bg-indigo-500
pointer-events-none -translate-x-1/2 -translate-y-1/2 z-50"
style={{ x: sx, y: sy }}
/>
)
}
Schimmer-Skelett
"use client"
import { useEffect } from "react"
import { motion, useAnimation } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
export function ShimmerSkeleton({ className = "" }: { className?: string }) {
const controls = useAnimation()
useEffect(() => {
const play = () =>
controls.start({
x: ["-100%", "100%"],
transition: {
repeat: Infinity,
duration: motionTokens.duration.crawl,
ease: motionTokens.easing.linear,
},
})
const handleVisibility = () => {
if (document.visibilityState === "hidden") controls.stop()
else void play()
}
void play()
document.addEventListener("visibilitychange", handleVisibility)
return () => {
controls.stop()
document.removeEventListener("visibilitychange", handleVisibility)
}
}, [controls])
return (
<div className={`relative overflow-hidden bg-gray-200 rounded ${className}`}>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/60 to-transparent"
initial={{ x: "-100%" }}
animate={controls}
/>
</div>
)
}
Button-Ladezustand
"use client"
import { motion, AnimatePresence } from "motion/react"
import { motionTokens, springs } from "@/lib/motion-tokens"
export function LoadingButton({
loading,
label,
onClick,
}: {
loading: boolean
label: string
onClick: () => void
}) {
return (
<motion.button
onClick={onClick}
animate={{ opacity: loading ? 0.7 : 1 }}
whileTap={loading ? {} : { scale: motionTokens.scale.press }}
transition={springs.snappy}
disabled={loading}
>
<AnimatePresence mode="wait">
{loading ? (
<motion.span
key="loading"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: motionTokens.duration.fast }}
>
…
</motion.span>
) : (
<motion.span
key="label"
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
transition={{ duration: motionTokens.duration.fast }}
>
{label}
</motion.span>
)}
</AnimatePresence>
</motion.button>
)
}
Unendliche Animation mit Sichtbarkeitspause
"use client"
import { useEffect } from "react"
import { motion, useAnimation } from "motion/react"
import { motionTokens } from "@/lib/motion-tokens"
export function PulseDot() {
const controls = useAnimation()
useEffect(() => {
const pulse = () =>
controls.start({
scale: [1, 1.4, 1],
opacity: [1, 0.6, 1],
transition: { repeat: Infinity, duration: motionTokens.duration.crawl },
})
// Regel 2: Pausieren, wenn der Tab versteckt ist
const handleVisibility = () => {
if (document.visibilityState === "hidden") controls.stop()
else void pulse()
}
void pulse()
document.addEventListener("visibilitychange", handleVisibility)
return () => {
controls.stop()
document.removeEventListener("visibilitychange", handleVisibility)
}
}, [controls])
return (
<motion.div
className="w-3 h-3 rounded-full bg-indigo-500"
animate={controls}
/>
)
}
Benutzerdefinierte useHoverScale-Anleitung
"use client"
import { useMotionValue, useSpring } from "motion/react"
import { springs } from "@/lib/motion-tokens"
export function useHoverScale(scaleTo = 1.05) {
const scaleVal = useMotionValue(1)
const scale = useSpring(scaleVal, springs.gentle)
return {
style: { scale },
onMouseEnter: () => scaleVal.set(scaleTo),
onMouseLeave: () => scaleVal.set(1),
onFocus: () => scaleVal.set(scaleTo),
onBlur: () => scaleVal.set(1),
}
}
// Verwendung
const hover = useHoverScale()
<motion.button {...hover}>Hover über mich</motion.button>