לדלג לתוכן

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 על סימן + כשהרשימה ריקה

תשובות לשאלות

  1. spring לעומת tween - spring מדמה פיזיקה של קפיץ ומרגישה טבעית יותר. tween היא אנימציה מבוססת זמן עם easing function. נעדיף spring לתנועות UI (פתיחת מודאל, כפתורים), ו-tween לאנימציות מדויקות שצריכות להסתיים בזמן ספציפי (progress bar, countdown).

  2. AnimatePresence ו-key - AnimatePresence עוקבת אחרי ילדים שנכנסים ויוצאים מה-DOM ומאפשרת להריץ אנימציית exit לפני הסרה. ה-key נדרש כדי ש-React ו-Framer Motion יזהו מתי אלמנט הוסר ומתי הוחלף - בלי key ייחודי, ריאקט פשוט מעדכן את האלמנט הקיים.

  3. whileHover לעומת animate - animate מגדיר את מצב היעד הקבוע של האנימציה (מה שנראה תמיד). whileHover מפעיל אנימציה רק כשהעכבר מעל האלמנט ומחזיר את המצב הקודם כשהוא עוזב. נשתמש ב-animate לאנימציות כניסה ומצבים קבועים, ו-whileHover לאפקטים אינטראקטיביים.

  4. layout prop - Framer Motion מודד את המיקום והגודל של האלמנט לפני ואחרי render. אם הם שונים, הוא מנפיש את המעבר באמצעות transform (לא שינוי בפועל של width/height שגורם ל-reflow). זה מאפשר אנימציות חלקות של שינויי layout ללא עלות ביצועים.

  5. variants - variants מאפשרים: ארגון מצבי אנימציה עם שמות קריאים, ירושה אוטומטית לילדים (staggerChildren), שימוש חוזר באותם מצבים, והפרדה בין הגדרת האנימציה לשימוש בה. בלי variants, כל קומפוננטה צריכה לשכפל את אותם אובייקטי animate.