לדלג לתוכן

8.7 אנימציות הרצאה

אנימציות - Framer Motion

בשיעור זה נלמד כיצד להוסיף אנימציות מקצועיות לאפליקציות ריאקט באמצעות ספריית Framer Motion. נכיר את הבסיס, ונתקדם ל-variants, AnimatePresence, gestures ואנימציות layout.


למה אנימציות חשובות

  • אנימציות מספקות משוב ויזואלי למשתמש (לחיצה, טעינה, הצלחה)
  • הן מנחות את תשומת הלב לשינויים בממשק
  • מעברים חלקים בין מצבים מרגישים מקצועיים ונעימים
  • אנימציות מפחיתות תחושת המתנה ומגבירות תחושת מהירות
  • הן חלק בלתי נפרד מעיצוב UX מודרני

התקנה ובסיס

npm install framer-motion

קומפוננטות 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