8.7 אנימציות פתרון
פתרון - אנימציות¶
פתרון תרגיל 1¶
import { motion, Variants } from "framer-motion";
type ButtonVariant = "primary" | "success" | "danger";
const colors: Record<ButtonVariant, { bg: string; hover: string }> = {
primary: { bg: "#2196F3", hover: "#1976D2" },
success: { bg: "#4CAF50", hover: "#388E3C" },
danger: { bg: "#F44336", hover: "#D32F2F" },
};
const buttonVariants: Variants = {
hidden: { opacity: 0, scale: 0.8 },
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.4, ease: "easeOut" },
},
hover: {
scale: 1.05,
boxShadow: "0 8px 25px rgba(0,0,0,0.2)",
transition: { duration: 0.2 },
},
tap: {
scale: 0.95,
transition: { duration: 0.1 },
},
};
interface AnimatedButtonProps {
variant?: ButtonVariant;
children: React.ReactNode;
onClick?: () => void;
}
function AnimatedButton({
variant = "primary",
children,
onClick,
}: AnimatedButtonProps) {
const { bg, hover } = colors[variant];
return (
<motion.button
variants={buttonVariants}
initial="hidden"
animate="visible"
whileHover={{
...buttonVariants.hover,
backgroundColor: hover,
}}
whileTap="tap"
onClick={onClick}
style={{
padding: "12px 24px",
border: "none",
borderRadius: 8,
backgroundColor: bg,
color: "white",
cursor: "pointer",
fontSize: 16,
fontWeight: 500,
}}
>
{children}
</motion.button>
);
}
// שימוש
function App() {
return (
<div style={{ display: "flex", gap: 16, padding: 40 }}>
<AnimatedButton variant="primary">כפתור ראשי</AnimatedButton>
<AnimatedButton variant="success">הצלחה</AnimatedButton>
<AnimatedButton variant="danger">מחיקה</AnimatedButton>
</div>
);
}
- variants מגדיר את כל המצבים במקום אחד
- whileHover משתמש בהגדרות מה-variant עם צבע hover ספציפי
- spring transition ל-tap נותן תחושה טבעית
פתרון תרגיל 2¶
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1, delayChildren: 0.2 },
},
};
const cardVariants = {
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
interface CardData {
id: number;
title: string;
summary: string;
details: string;
}
const cards: CardData[] = [
{ id: 1, title: "ריאקט", summary: "ספרית UI", details: "ספריה לבניית ממשקי משתמש מבוססי קומפוננטות." },
{ id: 2, title: "TypeScript", summary: "טיפוסים סטטיים", details: "שפה שמוסיפה טיפוסים ל-JavaScript." },
{ id: 3, title: "Mantine", summary: "ספרית קומפוננטות", details: "קומפוננטות מוכנות עם עיצוב מקצועי." },
{ id: 4, title: "Framer Motion", summary: "אנימציות", details: "ספרית אנימציות הצהרתית לריאקט." },
{ id: 5, title: "Tailwind", summary: "Utility CSS", details: "מחלקות CSS שירותיות לעיצוב מהיר." },
{ id: 6, title: "Vite", summary: "כלי Build", details: "סביבת פיתוח מהירה לפרויקטי Frontend." },
];
function StaggeredCards() {
const [expandedId, setExpandedId] = useState<number | null>(null);
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 16,
maxWidth: 800,
padding: 40,
}}
>
{cards.map((card) => (
<motion.div
key={card.id}
variants={cardVariants}
layout
whileHover={{
y: -5,
boxShadow: "0 10px 30px rgba(0,0,0,0.15)",
}}
onClick={() =>
setExpandedId(expandedId === card.id ? null : card.id)
}
style={{
padding: 20,
backgroundColor: "white",
borderRadius: 12,
border: "1px solid #eee",
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
cursor: "pointer",
gridColumn: expandedId === card.id ? "span 3" : "span 1",
}}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
<motion.h3 layout style={{ margin: "0 0 8px 0" }}>
{card.title}
</motion.h3>
<motion.p layout style={{ margin: 0, color: "#666", fontSize: 14 }}>
{card.summary}
</motion.p>
<AnimatePresence>
{expandedId === card.id && (
<motion.p
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
style={{ marginTop: 12, color: "#333", overflow: "hidden" }}
>
{card.details}
</motion.p>
)}
</AnimatePresence>
</motion.div>
))}
</motion.div>
);
}
- staggerChildren: 0.1 גורם לכרטיסיות להיכנס ברצף
- layout prop מנפיש את שינוי gridColumn כש-expanded
- AnimatePresence בתוך כל כרטיסייה לתוכן הנוסף
פתרון תרגיל 3¶
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
interface FAQ {
question: string;
answer: string;
}
const faqs: FAQ[] = [
{ question: "מה זה ריאקט?", answer: "ריאקט היא ספרית JavaScript לבניית ממשקי משתמש מבוססי קומפוננטות. פותחה על ידי פייסבוק ומתוחזקת על ידי קהילה גדולה." },
{ question: "מה ההבדל בין ריאקט ל-Angular?", answer: "ריאקט היא ספריה שמתמקדת ב-UI, בעוד Angular הוא framework מלא. ריאקט גמישה יותר אך דורשת בחירת כלים נוספים." },
{ question: "מה זה TypeScript?", answer: "TypeScript היא שפה שמוסיפה טיפוסים סטטיים ל-JavaScript. היא עוזרת למנוע שגיאות ומשפרת את חוויית הפיתוח." },
{ question: "למה להשתמש ב-Mantine?", answer: "Mantine מספקת קומפוננטות מוכנות עם עיצוב מקצועי, הוקים שימושיים, תמיכה ב-TypeScript, ומערכת theme גמישה." },
{ question: "מה זה Framer Motion?", answer: "Framer Motion היא ספרית אנימציות הצהרתית לריאקט שמאפשרת ליצור אנימציות מורכבות עם API פשוט ואינטואיטיבי." },
];
function FAQSection() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
function toggleQuestion(index: number) {
setOpenIndex(openIndex === index ? null : index);
}
return (
<div style={{ maxWidth: 600, margin: "0 auto", padding: 40 }}>
<h2 style={{ marginBottom: 24 }}>שאלות נפוצות</h2>
{faqs.map((faq, index) => (
<div
key={index}
style={{
borderBottom: "1px solid #eee",
marginBottom: 4,
}}
>
<motion.button
onClick={() => toggleQuestion(index)}
style={{
width: "100%",
padding: "16px 0",
border: "none",
backgroundColor: "transparent",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: 16,
fontWeight: 500,
textAlign: "right",
}}
>
<span>{faq.question}</span>
<motion.span
animate={{ rotate: openIndex === index ? 45 : 0 }}
transition={{ duration: 0.2 }}
style={{ fontSize: 24, fontWeight: 300 }}
>
+
</motion.span>
</motion.button>
<AnimatePresence>
{openIndex === index && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
style={{ overflow: "hidden" }}
>
<p
style={{
padding: "0 0 16px 0",
margin: 0,
color: "#666",
lineHeight: 1.6,
}}
>
{faq.answer}
</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
);
}
- סימן + מסתובב ל-45 מעלות (נהיה X) כשפתוח
- height: "auto" עם AnimatePresence מנפיש פתיחה/סגירה
- overflow: hidden מונע תוכן שנראה מחוץ לאזור
פתרון תרגיל 4¶
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
const images = Array.from({ length: 9 }, (_, i) => ({
id: i + 1,
color: `hsl(${i * 40}, 70%, 80%)`,
}));
function ImageGallery() {
const [selectedId, setSelectedId] = useState<number | null>(null);
const selectedImage = images.find((img) => img.id === selectedId);
return (
<div style={{ padding: 40, maxWidth: 600, margin: "0 auto" }}>
<h2 style={{ marginBottom: 24 }}>גלריית תמונות</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 12,
}}
>
{images.map((image) => (
<motion.div
key={image.id}
layoutId={`image-${image.id}`}
onClick={() => setSelectedId(image.id)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
style={{
aspectRatio: "1",
backgroundColor: image.color,
borderRadius: 12,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 24,
fontWeight: 700,
color: "rgba(0,0,0,0.3)",
}}
>
{image.id}
</motion.div>
))}
</div>
<AnimatePresence>
{selectedId && selectedImage && (
<>
{/* רקע כהה */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedId(null)}
style={{
position: "fixed",
inset: 0,
backgroundColor: "rgba(0,0,0,0.7)",
zIndex: 100,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{/* תמונה מוגדלת */}
<motion.div
layoutId={`image-${selectedId}`}
style={{
width: "80vmin",
height: "80vmin",
maxWidth: 500,
maxHeight: 500,
backgroundColor: selectedImage.color,
borderRadius: 20,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 48,
fontWeight: 700,
color: "rgba(0,0,0,0.3)",
position: "relative",
}}
onClick={(e) => e.stopPropagation()}
>
{selectedImage.id}
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.3 } }}
onClick={() => setSelectedId(null)}
style={{
position: "absolute",
top: 12,
left: 12,
width: 32,
height: 32,
border: "none",
borderRadius: "50%",
backgroundColor: "rgba(0,0,0,0.3)",
color: "white",
cursor: "pointer",
fontSize: 18,
}}
>
X
</motion.button>
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}
- layoutId מחבר בין התמונה הקטנה והגדולה - Framer Motion מנפיש את המעבר
- stopPropagation מונע סגירה בלחיצה על התמונה עצמה
- כפתור X מופיע אחרי delay קצר
פתרון תרגיל 5¶
import { motion, AnimatePresence } from "framer-motion";
import { useState } from "react";
const pages = [
{
id: "home",
title: "דף הבית",
items: ["ברוכים הבאים לאתר", "כאן תמצאו מידע שימושי", "התחילו עכשיו"],
},
{
id: "features",
title: "תכונות",
items: ["מהירות גבוהה", "אבטחה מתקדמת", "עיצוב מודרני", "תמיכה 24/7"],
},
{
id: "contact",
title: "צור קשר",
items: ["טלפון: 03-1234567", "אימייל: info@example.com", "כתובת: תל אביב"],
},
];
const pageVariants = {
enter: { opacity: 0, x: 100 },
center: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -100 },
};
const itemVariants = {
hidden: { opacity: 0, y: 15 },
visible: { opacity: 1, y: 0 },
};
function PageTransitions() {
const [currentIndex, setCurrentIndex] = useState(0);
const currentPage = pages[currentIndex];
return (
<div style={{ maxWidth: 500, margin: "0 auto", padding: 40 }}>
{/* Progress bar */}
<div style={{ height: 4, backgroundColor: "#eee", borderRadius: 2, marginBottom: 24, overflow: "hidden" }}>
<motion.div
animate={{ width: `${((currentIndex + 1) / pages.length) * 100}%` }}
transition={{ duration: 0.5, ease: "easeInOut" }}
style={{ height: "100%", backgroundColor: "#2196F3", borderRadius: 2 }}
/>
</div>
{/* ניווט */}
<div style={{ display: "flex", gap: 8, marginBottom: 24 }}>
{pages.map((page, index) => (
<button
key={page.id}
onClick={() => setCurrentIndex(index)}
style={{
padding: "8px 16px",
border: "none",
borderRadius: 6,
backgroundColor: index === currentIndex ? "#2196F3" : "#eee",
color: index === currentIndex ? "white" : "#333",
cursor: "pointer",
}}
>
{page.title}
</button>
))}
</div>
{/* תוכן */}
<AnimatePresence mode="wait">
<motion.div
key={currentPage.id}
variants={pageVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<h2 style={{ marginBottom: 16 }}>{currentPage.title}</h2>
<motion.div
initial="hidden"
animate="visible"
transition={{ staggerChildren: 0.1, delayChildren: 0.2 }}
>
{currentPage.items.map((item) => (
<motion.div
key={item}
variants={itemVariants}
transition={{ duration: 0.3 }}
style={{
padding: "12px 16px",
marginBottom: 8,
backgroundColor: "#f5f5f5",
borderRadius: 8,
}}
>
{item}
</motion.div>
))}
</motion.div>
</motion.div>
</AnimatePresence>
</div>
);
}
- progress bar מונפש עם width שמשתנה
- mode="wait" מוודא שאנימציית היציאה מסתיימת לפני הכניסה
- stagger פנימי על הפריטים בכל דף
פתרון תרגיל 6¶
import { motion, AnimatePresence, Reorder } from "framer-motion";
import { useState } from "react";
interface Todo {
id: number;
text: string;
completed: boolean;
}
function AnimatedTodo() {
const [todos, setTodos] = useState<Todo[]>([]);
const [input, setInput] = useState("");
const [nextId, setNextId] = useState(1);
function addTodo() {
if (!input.trim()) return;
setTodos([...todos, { id: nextId, text: input, completed: false }]);
setNextId(nextId + 1);
setInput("");
}
function toggleTodo(id: number) {
setTodos(todos.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)));
}
function removeTodo(id: number) {
setTodos(todos.filter((t) => t.id !== id));
}
return (
<div style={{ maxWidth: 450, margin: "0 auto", padding: 40 }}>
<h2 style={{ marginBottom: 20 }}>רשימת משימות</h2>
{/* שדה הוספה */}
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addTodo()}
placeholder="הוסף משימה..."
style={{
flex: 1,
padding: "10px 16px",
border: "1px solid #ddd",
borderRadius: 8,
fontSize: 14,
}}
/>
<motion.button
onClick={addTodo}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
style={{
padding: "10px 20px",
border: "none",
borderRadius: 8,
backgroundColor: "#2196F3",
color: "white",
cursor: "pointer",
}}
>
הוסף
</motion.button>
</div>
{/* רשימה ריקה */}
<AnimatePresence>
{todos.length === 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
style={{
textAlign: "center",
padding: 40,
color: "#999",
}}
>
<motion.div
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 1.5, repeat: Infinity }}
style={{ fontSize: 32, marginBottom: 12 }}
>
+
</motion.div>
<p>אין משימות. הוסף את הראשונה!</p>
</motion.div>
)}
</AnimatePresence>
{/* רשימת משימות */}
<Reorder.Group
axis="y"
values={todos}
onReorder={setTodos}
style={{ listStyle: "none", padding: 0, margin: 0 }}
>
<AnimatePresence>
{todos.map((todo) => (
<Reorder.Item
key={todo.id}
value={todo}
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
exit={{
opacity: 0,
x: -100,
height: 0,
marginBottom: 0,
padding: 0,
transition: { duration: 0.3 },
}}
layout
style={{
padding: "12px 16px",
marginBottom: 8,
backgroundColor: todo.completed ? "#f0f9f0" : "#f5f5f5",
borderRadius: 8,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
cursor: "grab",
listStyle: "none",
transition: "background-color 0.2s",
}}
whileDrag={{
scale: 1.02,
boxShadow: "0 5px 15px rgba(0,0,0,0.15)",
cursor: "grabbing",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<motion.input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
whileTap={{ scale: 1.2 }}
style={{ cursor: "pointer", width: 18, height: 18 }}
/>
<motion.span
animate={{
color: todo.completed ? "#999" : "#333",
textDecoration: todo.completed ? "line-through" : "none",
}}
transition={{ duration: 0.3 }}
>
{todo.text}
</motion.span>
</div>
<motion.button
onClick={() => removeTodo(todo.id)}
whileHover={{ scale: 1.2, color: "#f44336" }}
whileTap={{ scale: 0.9 }}
style={{
border: "none",
backgroundColor: "transparent",
cursor: "pointer",
fontSize: 16,
color: "#999",
}}
>
X
</motion.button>
</Reorder.Item>
))}
</AnimatePresence>
</Reorder.Group>
</div>
);
}
- Reorder.Group ו-Reorder.Item מאפשרים גרירה לשינוי סדר
- AnimatePresence בתוך Reorder.Group לאנימציות כניסה/יציאה
- layout prop מנפיש את הסידור מחדש
- אנימציית pulse על סימן + כשהרשימה ריקה
תשובות לשאלות¶
-
spring לעומת tween - spring מדמה פיזיקה של קפיץ ומרגישה טבעית יותר. tween היא אנימציה מבוססת זמן עם easing function. נעדיף spring לתנועות UI (פתיחת מודאל, כפתורים), ו-tween לאנימציות מדויקות שצריכות להסתיים בזמן ספציפי (progress bar, countdown).
-
AnimatePresence ו-key - AnimatePresence עוקבת אחרי ילדים שנכנסים ויוצאים מה-DOM ומאפשרת להריץ אנימציית exit לפני הסרה. ה-key נדרש כדי ש-React ו-Framer Motion יזהו מתי אלמנט הוסר ומתי הוחלף - בלי key ייחודי, ריאקט פשוט מעדכן את האלמנט הקיים.
-
whileHover לעומת animate - animate מגדיר את מצב היעד הקבוע של האנימציה (מה שנראה תמיד). whileHover מפעיל אנימציה רק כשהעכבר מעל האלמנט ומחזיר את המצב הקודם כשהוא עוזב. נשתמש ב-animate לאנימציות כניסה ומצבים קבועים, ו-whileHover לאפקטים אינטראקטיביים.
-
layout prop - Framer Motion מודד את המיקום והגודל של האלמנט לפני ואחרי render. אם הם שונים, הוא מנפיש את המעבר באמצעות transform (לא שינוי בפועל של width/height שגורם ל-reflow). זה מאפשר אנימציות חלקות של שינויי layout ללא עלות ביצועים.
-
variants - variants מאפשרים: ארגון מצבי אנימציה עם שמות קריאים, ירושה אוטומטית לילדים (staggerChildren), שימוש חוזר באותם מצבים, והפרדה בין הגדרת האנימציה לשימוש בה. בלי variants, כל קומפוננטה צריכה לשכפל את אותם אובייקטי animate.