Animated SVG icon with Framer Motion


Lần đầu tiên mình thấy SVG icon động là khi Resend.com ra mắt. Chúng giúp mình cảm giác bớt sự đơn điệu của dashboard khi chỉ toàn màu đen. Lúc đó, mình đã tự hỏi bao giờ mình có thể làm được thứ tương tự.

Qua hai năm, sau nhiều bài viết trên X, khóa học như animations.dev, hay các bài chia sẻ của nandafyi, mình nghĩ mình đã hiểu thêm về cách SVG hoạt động.

Quay lại vài tháng trước, mình đã thử nghiệm với vài icon từ lucide-react để xem liệu việc animate icon có khó không. Và …nó không khó như mình tưởng: /experiments.

Animate Icon

Trước khi đi sâu vào cách animate icon, bạn có lẽ muốn tìm hiểu thêm về SVG qua Bài chia sẻ về SVG từ AOS 2023.

Trong bài viết này, thay vì sử dụng builtin animation của SVG hay dùng CSS để animate, chúng ta sẽ sử dụng Framer Motion để tạo hiệu ứng cho icon. Framer Motion là 1 thư viện animation mạnh, dễ dùng, kể cả với người mới.

Dưới đây là một file SVG đơn giản:

<svg
  xmlns="http://www.w3.org/2000/svg"
  width="24"
  height="24"
  viewBox="0 0 24 24"
  fill="none"
  stroke="currentColor"
  stroke-width="2"
  stroke-linecap="round"
  stroke-linejoin="round"
  class="lucide lucide-wrench"
>
  <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>

Thành phần chính của SVG này là <path>, trong đó d (path data) mô tả cách path được vẽ.

Để thêm hiệu ứng cho icon, mình sử dụng attribute pathLength. Hiểu đơn giản, nó cho phép “vẽ” path từ 0% đến 100% mà không cần chúng ta xử lý từng điểm. Kết hợp với Framer Motion, bạn có thể đạt được hiệu ứng như sau:

import { motion } from "framer-motion"; export default function Example1() { return ( <> Default: <br /> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-24 h-24" > <path d="M3 3v16a2 2 0 0 0 2 2h16" /> <path d="M7 16h8" /> <path d="M7 11h12" /> <motion.path d="M7 6h3" /> </svg> <br /> <br /> Animated: <br /> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-24 h-24" > <path d="M3 3v16a2 2 0 0 0 2 2h16" /> <motion.path d="M7 16h8" initial={{ pathLength: 0 }} animate={{ pathLength: [0, 1, 0] }} transition={{ duration: 1, repeat: Infinity }} /> <motion.path d="M7 11h12" initial={{ pathLength: 0 }} animate={{ pathLength: [0, 1, 1.2, 1, 0] }} transition={{ duration: 1, delay: 0.2, repeat: Infinity }} /> <motion.path d="M7 6h3" initial={{ pathLength: 0 }} animate={{ pathLength: [0, 1, 1.2, 1.4, 1.2, 1, 0] }} transition={{ duration: 1, delay: 0.4, repeat: Infinity }} /> </svg> </> ); }

Cách hoạt động

  1. Đổi qua dùng motion.path import từ Framer Motion thay vì path thông thường để dùng các function animation của Framer.
  2. Định nghĩa animation cho mỗi path:
    • initial: Trạng thái ban đầu, ví dụ pathLength: 0 (path chưa được vẽ).
    • animate: Các trạng thái animation mà path sẽ đi qua.
    • transition: Cấu hình thêm cho animation như duration, delay, hoặc repeat.
  3. Trong ví dụ trên, có ba path với các animation khác nhau:
    • Path 1: pathLength: [0, 1, 0] - vẽ từ 0 đến 100% rồi reset về 0.
    • Path 2: pathLength: [0, 1, 1.2, 1, 0] - vẽ từ 0 đến 100%, vượt quá rồi quay lại và reset.
    • Path 3: pathLength: [0, 1, 1.2, 1.4, 1.2, 1, 0] - tương tự nhưng 2 cái trên nhưng sẽ phức tạp hơn xíu.

Mỗi path được đặt delay khác nhau (0, 0.2, 0.4) để tạo hiệu ứng tuần tự. Tất cả đều set repeat: Infinity để lặp vô hạn.

Chúng ta có thể thấy sử dụng pathLength là cách đơn giản nhưng hiệu quả để animate 1 SVG icon. Nếu muốn nâng cao hơn, bạn có thể kết hợp với các tính năng khác của Framer Motion, ví dụ với Gesture.

Một vài ví dụ khi kết hợp

Transform Icon khi click

Kết hợp nhiều path và toggle trạng thái visible của các path linh hoạt.

import { motion } from "framer-motion"; import { useState } from "react"; const Volume = () => { const [mute, setMute] = useState(false); return ( <button type="button" style={{ appearance: "none", background: "none", border: "none", cursor: "pointer", }} onClick={() => setMute((mute) => !mute)} > <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: "50px", height: "50px" }} > <motion.path d="M4.89143 7.00951H2.69269C2.62605 7.00951 2.56213 7.03599 2.51501 7.08311C2.46788 7.13024 2.44141 7.19416 2.44141 7.2608V10.7789C2.44141 10.8455 2.46788 10.9094 2.51501 10.9565C2.56213 11.0037 2.62605 11.0301 2.69269 11.0301H4.88044C4.99628 11.0292 5.10885 11.0685 5.19894 11.1413L8.07207 13.4937C8.10947 13.5212 8.15376 13.5379 8.20003 13.5417C8.2463 13.5456 8.29273 13.5366 8.33418 13.5156C8.37563 13.4947 8.41047 13.4627 8.43484 13.4232C8.45921 13.3837 8.47216 13.3382 8.47224 13.2918V4.7479C8.47216 4.70147 8.45921 4.65598 8.43484 4.61646C8.41047 4.57694 8.37563 4.54494 8.33418 4.52402C8.29273 4.5031 8.2463 4.49408 8.20003 4.49794C8.15376 4.50181 8.10947 4.51842 8.07207 4.54593L5.19894 6.89832C5.113 6.9709 5.00392 7.01035 4.89143 7.00951Z" stroke="currentColor" strokeWidth="1.11683" strokeLinecap="round" strokeLinejoin="round" animate={{ x: mute ? 2.5 : 0 }} /> <motion.path d="M12.0488 12.5381C12.6607 11.4726 13.054 10.5259 13.054 9.02001C13.054 7.51416 12.677 6.5781 12.0488 5.50195" stroke="currentColor" strokeWidth="1.11683" strokeLinecap="round" strokeLinejoin="round" animate={{ x: mute ? -4 : 0, opacity: mute ? 0 : 1 }} transition={{ delay: 0.05 }} /> <motion.path d="M13.5586 14.0457C14.5009 12.6008 15.0663 11.1738 15.0663 9.01994C15.0663 6.86607 14.5009 5.47047 13.5586 3.99414" stroke="currentColor" strokeWidth="1.11683" strokeLinecap="round" strokeLinejoin="round" animate={{ x: mute ? -4 : 0, opacity: mute ? 0 : 1 }} transition={{ delay: 0.1 }} /> <motion.path d="M10.5381 11.0304C10.844 10.4217 11.0407 9.74757 11.0407 9.02008C11.0407 8.28255 10.8522 7.6248 10.5381 7.00977" stroke="currentColor" strokeWidth="1.11683" strokeLinecap="round" strokeLinejoin="round" animate={{ x: mute ? -4 : 0, opacity: mute ? 0 : 1 }} /> <motion.path d="M14.6698 14.7159L3.61328 3.65918" stroke="currentColor" strokeWidth="1.11683" strokeMiterlimit="10" strokeLinecap="round" animate={{ pathLength: mute ? 1 : 0, opacity: mute ? 1 : 0 }} /> </svg> </button> ); }; export default Volume;

Animate khi hover icon

import { motion } from "framer-motion"; import { useState } from "react"; const FullScreen = () => { return ( <motion.svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ cursor: "pointer", height: "50px", width: "50px" }} // dùng để thêm hiệu ứng hover whileHover="hover" > <motion.path d="M8 3H5a2 2 0 0 0-2 2v3" variants={{ hover: { scale: 1.1, x: -2, y: -2 }, }} transition={{ duration: 0.2, type: "tween" }} /> <motion.path d="M21 8V5a2 2 0 0 0-2-2h-3" variants={{ hover: { scale: 1.1, x: 2, y: -2 }, }} transition={{ duration: 0.2, type: "tween" }} /> <motion.path d="M3 16v3a2 2 0 0 0 2 2h3" variants={{ hover: { scale: 1.1, x: -2, y: 2 }, }} transition={{ duration: 0.2, type: "tween" }} /> <motion.path d="M16 21h3a2 2 0 0 0 2-2v-3" variants={{ hover: { scale: 1.1, x: 2, y: 2 }, }} transition={{ duration: 0.2, type: "tween" }} /> </motion.svg> ); }; export default FullScreen;

Animate icon của riêng bạn

Nếu đã quen với Framer Motion, việc tạo ra 1 animated icon có vẻ không khó. Nhưng nếu mới bắt đầu, hãy tham khảo các cách sau:

  1. Tham khảo open source animated icon gần đây: https://icons.pqoqubbw.dev/
  2. /experiments code
  3. Sử dụng AI để hỗ trợ:
    • Gửi file SVG của icon bạn muốn animate cho AI (Cursor, AI IDE/Claude).
    • Mô tả hiệu ứng mong muốn hoặc đính kèm hình minh họa để nhờ AI tạo ra animation tương ứng với icon.
    • Copy đoạn code AI tạo ra và tích hợp vào project của bạn.

Kết

Nhiều người, kể cả mình, từng nghĩ rằng thêm animation vào ứng dụng là việc khó và tốn kém. Nhưng với các công cụ hiện đại (như Framer Motion) và sự hỗ trợ từ AI, việc animate thứ gì đó đã trở nên đơn giản hơn rất nhiều. Khiến 1 app đang nhàm chán trở nên tốt hơn rất nhiều. Hy vọng bạn sẽ thử nghiệm và mang đến nhiều hiệu ứng thú vị trong dự án của mình!

Đọc thêm: