8.7 אנימציות הרצאה
אנימציות - Framer Motion¶
בשיעור זה נלמד כיצד להוסיף אנימציות מקצועיות לאפליקציות ריאקט באמצעות ספריית Framer Motion. נכיר את הבסיס, ונתקדם ל-variants, AnimatePresence, gestures ואנימציות layout.
למה אנימציות חשובות¶
- אנימציות מספקות משוב ויזואלי למשתמש (לחיצה, טעינה, הצלחה)
- הן מנחות את תשומת הלב לשינויים בממשק
- מעברים חלקים בין מצבים מרגישים מקצועיים ונעימים
- אנימציות מפחיתות תחושת המתנה ומגבירות תחושת מהירות
- הן חלק בלתי נפרד מעיצוב UX מודרני
התקנה ובסיס¶
קומפוננטות motion¶
Framer Motion מספקת גרסאות motion של אלמנטי HTML:
import { motion } from "framer-motion";
function BasicAnimation() {
return (
<motion.div
animate={{ opacity: 1, scale: 1 }}
initial={{ opacity: 0, scale: 0.5 }}
transition={{ duration: 0.5 }}
>
<h1>שלום עולם!</h1>
</motion.div>
);
}
- motion.div, motion.button, motion.span - כל אלמנט HTML
- initial - מצב ההתחלה
- animate - מצב היעד
- transition - הגדרות המעבר
תכונות אנימציה¶
import { motion } from "framer-motion";
function AnimationProperties() {
return (
<>
{/* תזוזה */}
<motion.div animate={{ x: 100, y: 50 }}>תזוזה</motion.div>
{/* סיבוב */}
<motion.div animate={{ rotate: 360 }}>סיבוב</motion.div>
{/* גודל */}
<motion.div animate={{ scale: 1.5 }}>הגדלה</motion.div>
{/* שקיפות */}
<motion.div animate={{ opacity: 0.5 }}>חצי שקוף</motion.div>
{/* רוחב וגובה */}
<motion.div animate={{ width: 200, height: 100 }}>גודל</motion.div>
{/* צבעים */}
<motion.div
animate={{ backgroundColor: "#ff0000", color: "#ffffff" }}
>
צבע
</motion.div>
{/* פינות */}
<motion.div animate={{ borderRadius: "50%" }}>עיגול</motion.div>
{/* שילוב */}
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
שילוב אנימציות
</motion.div>
</>
);
}
הגדרות מעבר - Transition¶
import { motion } from "framer-motion";
function TransitionExamples() {
return (
<>
{/* Duration ו-delay */}
<motion.div
animate={{ x: 100 }}
transition={{ duration: 0.8, delay: 0.2 }}
/>
{/* Ease functions */}
<motion.div
animate={{ x: 100 }}
transition={{ ease: "easeInOut" }}
/>
{/* Spring (קפיץ) */}
<motion.div
animate={{ x: 100 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
/>
{/* Spring עם bounce */}
<motion.div
animate={{ scale: 1 }}
initial={{ scale: 0 }}
transition={{
type: "spring",
bounce: 0.5,
duration: 0.8,
}}
/>
{/* חזרה אינסופית */}
<motion.div
animate={{ rotate: 360 }}
transition={{
duration: 2,
repeat: Infinity,
ease: "linear",
}}
/>
{/* יויו - חזרה קדימה ואחורה */}
<motion.div
animate={{ scale: 1.1 }}
transition={{
duration: 0.5,
repeat: Infinity,
repeatType: "reverse",
}}
/>
</>
);
}
- type: "spring" - אנימציה פיזיקלית טבעית
- type: "tween" (ברירת מחדל) - אנימציה רגילה מבוססת זמן
- stiffness - קשיחות הקפיץ
- damping - עמעום (כמה מהר הקפיץ נרגע)
- bounce - כמה הקפיץ קופץ (0 = ללא, 1 = הרבה)
Variants - ניהול מצבים¶
Variants מאפשרים להגדיר מצבי אנימציה עם שם ולהעביר ביניהם:
import { motion } from "framer-motion";
import { useState } from "react";
const cardVariants = {
hidden: {
opacity: 0,
y: 20,
scale: 0.95,
},
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: {
duration: 0.5,
ease: "easeOut",
},
},
hover: {
y: -5,
boxShadow: "0 10px 30px rgba(0,0,0,0.15)",
transition: {
duration: 0.2,
},
},
tap: {
scale: 0.98,
},
};
function AnimatedCard() {
return (
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
whileHover="hover"
whileTap="tap"
style={{
padding: 24,
borderRadius: 12,
backgroundColor: "white",
border: "1px solid #eee",
cursor: "pointer",
}}
>
<h3>כרטיסייה מונפשת</h3>
<p>העבר עכבר או לחץ</p>
</motion.div>
);
}
הנפשת ילדים עם staggerChildren¶
import { motion } from "framer-motion";
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.2,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4 },
},
};
function StaggeredList() {
const items = ["פריט 1", "פריט 2", "פריט 3", "פריט 4", "פריט 5"];
return (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
style={{ listStyle: "none", padding: 0 }}
>
{items.map((item) => (
<motion.li
key={item}
variants={itemVariants}
style={{
padding: "12px 16px",
marginBottom: 8,
backgroundColor: "#f5f5f5",
borderRadius: 8,
}}
>
{item}
</motion.li>
))}
</motion.ul>
);
}
- staggerChildren - השהייה בין הנפשת כל ילד
- delayChildren - השהייה לפני הנפשת הילד הראשון
- הילדים יורשים את שמות ה-variants מההורה
AnimatePresence - אנימציות כניסה ויציאה¶
AnimatePresence מאפשר להנפיש אלמנטים שנכנסים ויוצאים מה-DOM:
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
function ToggleContent() {
const [isVisible, setIsVisible] = useState(true);
return (
<>
<button onClick={() => setIsVisible(!isVisible)}>
{isVisible ? "הסתר" : "הצג"}
</button>
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
style={{ overflow: "hidden" }}
>
<div style={{ padding: 20 }}>
<h3>תוכן שנכנס ויוצא</h3>
<p>עם אנימציה חלקה</p>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
החלפת אלמנטים¶
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
const pages = [
{ id: 1, title: "דף ראשון", color: "#4CAF50" },
{ id: 2, title: "דף שני", color: "#2196F3" },
{ id: 3, title: "דף שלישי", color: "#FF9800" },
];
function PageSwitcher() {
const [currentPage, setCurrentPage] = useState(0);
return (
<div>
<div style={{ display: "flex", gap: 8, marginBottom: 16 }}>
{pages.map((page, index) => (
<button key={page.id} onClick={() => setCurrentPage(index)}>
{page.title}
</button>
))}
</div>
<AnimatePresence mode="wait">
<motion.div
key={currentPage}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -50 }}
transition={{ duration: 0.3 }}
style={{
padding: 40,
borderRadius: 12,
backgroundColor: pages[currentPage].color,
color: "white",
}}
>
<h2>{pages[currentPage].title}</h2>
</motion.div>
</AnimatePresence>
</div>
);
}
- mode="wait" - מחכה שהאנימציה של היוצא תסתיים לפני שהנכנס מתחיל
- key חייב להשתנות כדי ש-AnimatePresence יזהה החלפה
רשימה עם הוספה והסרה¶
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
function AnimatedList() {
const [items, setItems] = useState([1, 2, 3]);
const [nextId, setNextId] = useState(4);
function addItem() {
setItems([...items, nextId]);
setNextId(nextId + 1);
}
function removeItem(id: number) {
setItems(items.filter((item) => item !== id));
}
return (
<div>
<button onClick={addItem}>הוסף פריט</button>
<AnimatePresence>
{items.map((item) => (
<motion.div
key={item}
initial={{ opacity: 0, x: -100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100 }}
transition={{ duration: 0.3 }}
layout
style={{
padding: "12px 16px",
margin: "8px 0",
backgroundColor: "#f0f0f0",
borderRadius: 8,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>פריט {item}</span>
<button onClick={() => removeItem(item)}>X</button>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
Gestures - מחוות¶
hover ו-tap¶
import { motion } from "framer-motion";
function GestureExamples() {
return (
<>
{/* Hover */}
<motion.button
whileHover={{ scale: 1.05, backgroundColor: "#4CAF50" }}
whileTap={{ scale: 0.95 }}
transition={{ duration: 0.15 }}
style={{
padding: "12px 24px",
border: "none",
borderRadius: 8,
backgroundColor: "#2196F3",
color: "white",
cursor: "pointer",
fontSize: 16,
}}
>
לחץ עליי
</motion.button>
{/* כרטיס עם hover מורכב */}
<motion.div
whileHover={{
y: -8,
boxShadow: "0 20px 40px rgba(0,0,0,0.2)",
}}
transition={{ duration: 0.3 }}
style={{
padding: 24,
borderRadius: 12,
backgroundColor: "white",
border: "1px solid #eee",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
cursor: "pointer",
}}
>
כרטיס עם hover
</motion.div>
</>
);
}
Drag - גרירה¶
import { motion } from "framer-motion";
function DragExamples() {
return (
<>
{/* גרירה חופשית */}
<motion.div
drag
dragConstraints={{ top: -100, bottom: 100, left: -100, right: 100 }}
whileDrag={{ scale: 1.1, cursor: "grabbing" }}
style={{
width: 100,
height: 100,
backgroundColor: "#4CAF50",
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
cursor: "grab",
}}
>
גרור
</motion.div>
{/* גרירה בציר אחד */}
<motion.div
drag="x"
dragConstraints={{ left: 0, right: 300 }}
dragElastic={0.2}
style={{
width: 60,
height: 30,
backgroundColor: "#2196F3",
borderRadius: 15,
}}
/>
</>
);
}
- drag - מאפשר גרירה (true, "x", "y")
- dragConstraints - גבולות הגרירה
- dragElastic - כמה גמיש מחוץ לגבולות (0 = קשיח, 1 = גמיש)
Layout Animations - אנימציות פריסה¶
import { motion } from "framer-motion";
import { useState } from "react";
function LayoutAnimationExample() {
const [isExpanded, setIsExpanded] = useState(false);
return (
<motion.div
layout
onClick={() => setIsExpanded(!isExpanded)}
style={{
width: isExpanded ? 300 : 150,
height: isExpanded ? 200 : 80,
backgroundColor: "#6C63FF",
borderRadius: 16,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
}}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<motion.span layout>{isExpanded ? "לחץ לכיווץ" : "לחץ להרחבה"}</motion.span>
</motion.div>
);
}
רשת שמשתנה¶
import { motion, LayoutGroup } from "framer-motion";
import { useState } from "react";
function LayoutGrid() {
const [selected, setSelected] = useState<number | null>(null);
const items = [1, 2, 3, 4, 5, 6];
return (
<LayoutGroup>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 12,
maxWidth: 400,
}}
>
{items.map((item) => (
<motion.div
key={item}
layout
onClick={() => setSelected(selected === item ? null : item)}
style={{
padding: 20,
backgroundColor: selected === item ? "#4CAF50" : "#f0f0f0",
borderRadius: 12,
cursor: "pointer",
gridColumn: selected === item ? "span 3" : "span 1",
color: selected === item ? "white" : "black",
}}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<motion.span layout>פריט {item}</motion.span>
</motion.div>
))}
</div>
</LayoutGroup>
);
}
- layout prop מאפשר אנימציות אוטומטיות כשהגודל או המיקום משתנים
- LayoutGroup מסנכרן אנימציות layout בין רכיבים
אנימציות מעבר דפים¶
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
const pageVariants = {
initial: {
opacity: 0,
y: 20,
},
in: {
opacity: 1,
y: 0,
},
out: {
opacity: 0,
y: -20,
},
};
const pageTransition = {
type: "tween",
ease: "easeInOut",
duration: 0.3,
};
function HomePage() {
return (
<div style={{ padding: 40 }}>
<h1>דף הבית</h1>
<p>ברוכים הבאים</p>
</div>
);
}
function AboutPage() {
return (
<div style={{ padding: 40 }}>
<h1>אודות</h1>
<p>מידע אודותינו</p>
</div>
);
}
function PageTransitionDemo() {
const [page, setPage] = useState<"home" | "about">("home");
return (
<div>
<nav style={{ display: "flex", gap: 8, padding: 16 }}>
<button onClick={() => setPage("home")}>בית</button>
<button onClick={() => setPage("about")}>אודות</button>
</nav>
<AnimatePresence mode="wait">
<motion.div
key={page}
variants={pageVariants}
initial="initial"
animate="in"
exit="out"
transition={pageTransition}
>
{page === "home" ? <HomePage /> : <AboutPage />}
</motion.div>
</AnimatePresence>
</div>
);
}
שילוב עם Mantine¶
import { motion, AnimatePresence } from "framer-motion";
import { Button, Paper, Title, Text, Stack, Group, Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
const MotionPaper = motion.create(Paper);
function MantineWithMotion() {
const [opened, { open, close }] = useDisclosure(false);
const [items, setItems] = useState(["פריט 1", "פריט 2", "פריט 3"]);
return (
<Stack gap="lg" p="xl">
<Title order={2}>Framer Motion + Mantine</Title>
{/* כרטיסיות מונפשות */}
<AnimatePresence>
{items.map((item, index) => (
<MotionPaper
key={item}
shadow="sm"
p="md"
withBorder
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 50 }}
transition={{ delay: index * 0.1 }}
layout
>
<Group justify="space-between">
<Text>{item}</Text>
<Button
variant="subtle"
color="red"
size="xs"
onClick={() =>
setItems(items.filter((i) => i !== item))
}
>
הסר
</Button>
</Group>
</MotionPaper>
))}
</AnimatePresence>
<Button
onClick={() =>
setItems([...items, `פריט ${items.length + 1}`])
}
>
הוסף פריט
</Button>
</Stack>
);
}
- motion.create(Paper) יוצר גרסת motion של קומפוננטת Mantine
- כך ניתן לשלב את כל התכונות של Mantine עם אנימציות Framer Motion
סיכום¶
- Framer Motion מאפשרת אנימציות הצהרתיות בריאקט עם API פשוט
- קומפוננטות motion (motion.div, motion.button) הן הבסיס
- initial, animate, exit מגדירים מצבי אנימציה
- transition מגדיר את אופי המעבר (duration, spring, ease)
- Variants מאפשרים ניהול מצבים מורכבים עם שמות
- staggerChildren מנפיש ילדים ברצף
- AnimatePresence מאפשר אנימציות יציאה (exit)
- whileHover, whileTap, drag מספקים אינטראקציות משתמש
- layout prop מנפיש שינויי גודל ומיקום אוטומטית
- motion.create מאפשר שילוב עם קומפוננטות Mantine