GODRICH

9 SVG Icon Animation CSS — dán 1 dòng là chạy

Svg icon animation css là cách dựng icon play, chuông, refresh bằng path SVG có toạ độ, tạo chuyển động bằng transform và opacity thay cho glyph chữ phụ thuộc

Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip

Thứ tự chín mục đi theo vai trò của icon trên màn hình: mở đầu là hai icon trang trí đứng yên rồi mới hút mắt (01, 02), tiếp đến năm nút bấm đổi trạng thái khi bạn nhấn (03 đến 07), và kết bằng hai icon trang trí lặng lẽ đứng cạnh chữ (08, 09). Cả chín chỉ động đến transform và opacity, riêng ba mục vẽ nét (01, 02, 09) còn dùng thêm stroke-dasharray và dashoffset — hai giá trị này thuộc bước paint chứ không kéo trình duyệt tính lại layout như top hay width, theo tài liệu stroke-dasharray của MDN.

01Vẽ nét stroke

Path ngôi sao mang pathLength="100" được vẽ ra bằng cách đổi stroke-dashoffset từ 100 về 0, dừng một nhịp, rồi xoá theo chiều ngược lại về -100, lặp tự động không cần JS trong $dur-enter 600ms. Đây là icon trang trí đứng cạnh dòng chữ hiển thị kiểu "tính năng mới", nên svg chỉ cần aria-hidden="true".

stroke-dasharray 100600ms빠르게 300ms
.badge__star {
  fill: none; stroke: $color; stroke-width: 1.6;
  stroke-dasharray: 100;                 // pathLength="100" nên coi dasharray như %
  animation: draw $duration $easing infinite;
}
@keyframes draw {
  0%   { stroke-dashoffset: 100; }
  55%  { stroke-dashoffset: 0; }
  80%  { stroke-dashoffset: 0; }
  100% { stroke-dashoffset: -100; }
}

02Logo reveal

Hai path hình thoi giống hệt nhau chồng lên nhau — một bản chỉ có nét, một bản tô đặc đang ẩn ở opacity 0 — nét vẽ xong thì bản tô đặc hiện ra, rồi quay lại chỉ còn nét. Toàn bộ vòng lặp này chạy trong $dur-enter 600ms bằng $ease-out, cạnh nó là chữ wordmark thương hiệu nên svg vẫn gắn aria-hidden="true".

stroke → fill 2단계600ms라인만 남기기
.mark__line { stroke-dasharray: 100; animation: line-draw $duration $easing infinite; }
@keyframes line-draw {
  0%   { stroke-dashoffset: 100; opacity: 1; }
  55%  { stroke-dashoffset: 0;   opacity: 1; }
  85%  { opacity: 0; }
  100% { stroke-dashoffset: 0;   opacity: 0; }
}
@keyframes fill-in {
  0%, 55%  { opacity: 0; }
  75%, 85% { opacity: 1; }
}

03Icon morph

Tam giác play và hai vạch pause nằm trong hai <svg> riêng, xếp chồng cùng một ô grid-area(1/1) rồi đổi chỗ cho nhau bằng opacity và scale(.85→1) trong $dur-quick 220ms bằng $ease-pop mỗi khi bạn click. Vì nút chỉ có icon, <title> bên trong svg được cập nhật đúng trạng thái và nút gắn aria-labelledby trỏ tới title đó.

opacity 크로스페이드220msscale .85→1
.ic {
  grid-area: 1 / 1; fill: currentColor;
  transition: opacity $duration $easing, transform $duration $easing;
  transform-box: fill-box; transform-origin: 50% 50%;
}
.ic--play  { opacity: 1; }
.ic--pause { opacity: 0; transform: scale(.85); }
.iconbtn[aria-pressed="true"] .ic--play  { opacity: 0; transform: scale(.85); }
.iconbtn[aria-pressed="true"] .ic--pause { opacity: 1; transform: scale(1); }

04Chuông rung

<g> bọc icon chuông rung sang hai bên quanh điểm treo trên đỉnh, còn chấm badge phía trên phồng lên xẹp xuống theo đúng nhịp đó trong $dur-base 300ms. Bản đầu chỉ rung ở 15% vòng lặp nên số khung hình đổi ảnh đo được quá thấp, phải kéo dài đoạn rung lên 30% mới đạt ngưỡng.

transform-box: fill-box진폭 8deg배지 pulse
.bell { transform-box: fill-box; transform-origin: 50% 0%; }
.iconbtn.is-demo .bell { animation: shake $dur-loop $easing infinite; }
@keyframes shake {
  0%, 30%, 100% { transform: rotate(0); }
  4%  { transform: rotate(8deg); }
  8%  { transform: rotate(-8deg); }
  16% { transform: rotate(-6deg); }
  24% { transform: rotate(-3deg); }
}

05Xoay refresh

Icon refresh gồm hai mũi tên không nằm giữa viewport 24×24, nên transform-box: fill-box đổi trục xoay sang đúng tâm hình dạng của chính nó thay vì góc trên trái SVG. Click một lần xoay trọn 360 độ trong $dur-enter 600ms bằng $ease-out, còn ở lưới xem trước nó tự xoay liên tục để bạn thấy chuyển động.

transform-box: fill-box360deg 600ms연속 회전(.is-demo)
.refresh {
  transform-box: fill-box;
  transform-origin: 50% 50%;
}
.iconbtn.is-spinning .refresh { animation: spin-once $duration $easing; }
@keyframes spin-once { to { transform: rotate(360deg); } }

06Menu icon morph

Icon ba chấm và icon chữ X nằm trong hai <svg> xếp chồng cùng ô grid, đổi chỗ cho nhau chỉ bằng opacity trong $dur-quick 220ms — khác hẳn kiểu hamburger ba vạch xoay chồng thành X, ở đây hoàn toàn không có phép xoay nào. Nút toggle aria-expanded và cập nhật <title> bên trong svg thành "mở menu" hoặc "đóng menu" cho screen reader.

점 3개 → Xopacity 크로스페이드220ms
.ic { grid-area: 1 / 1; fill: currentColor; transition: opacity $duration $easing; }
.ic--dots { opacity: 1; }
.ic--x    { opacity: 0; }
.iconbtn[aria-expanded="true"] .ic--dots { opacity: 0; }
.iconbtn[aria-expanded="true"] .ic--x    { opacity: 1; }
.iconbtn.is-demo .ic--x { animation: x-loop $dur-loop $easing infinite; }
@keyframes x-loop { 40%, 60% { opacity: 1; } }

07Heart pulse

Trái tim viền và trái tim tô đặc xếp chồng nhau, khi bạn nhấn thì bản tô đặc hiện ra còn cả hai cùng scale bật từ 1 lên 1.3 rồi lò xo về 1 trong $dur-quick 220ms bằng $ease-pop. Nút giữ aria-pressedaria-label="thích" để tên nút không đổi dù icon bên trong đang chuyển động.

scale 1→1.3→1220msfill 크로스페이드
.ic {
  grid-area: 1 / 1;
  transition: opacity $duration $easing, transform $duration $easing;
  transform-box: fill-box; transform-origin: 50% 50%;
}
.ic--fill { fill: $color; opacity: 0; }
.iconbtn[aria-pressed="true"] .ic--fill { opacity: 1; transform: scale(1); }
@keyframes fill-loop { 30% { transform: scale(1.3); } 40%, 60% { opacity: 1; } }

08Mũi tên di chuyển

Một path mũi tên trỏ xuống bồng bềnh lên xuống tại chỗ bằng translateY, lặp mỗi $dur-loop 2 giây với $ease-spring nên không dừng khựng ở hai đầu quãng đường. Icon này chỉ trang trí cạnh chữ "xem thêm", nên svg mang aria-hidden="true" còn ý nghĩa nằm hết ở phần chữ.

translateY 8px2s 루프ease-spring
$duration: $dur-loop;   // 2s
$easing: $ease-spring;
.cue__arrow { animation: bob $duration $easing infinite; }
@keyframes bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY($sp-2); }
}

09Vẽ check

Dấu check dạng polyline mang pathLength="100" được vẽ ra rồi xoá đi chỉ bằng cách đổi stroke-dashoffset, giống cách vẽ ở mục 01 nhưng dùng $dur-base 300ms ngắn hơn. Bản đầu vẽ icon ở 26px khiến diện tích pixel đổi màu đo được quá thấp so với ngưỡng, nên phải phóng lên 64px và tăng nét từ 2.4px lên 3.2px mới qua được.

stroke-dashoffsetpolyline 체크300ms
.confirm__check {
  stroke-dasharray: 100;
  animation: draw $duration $easing infinite;
}
@keyframes draw {
  0%   { stroke-dashoffset: 100; }
  55%  { stroke-dashoffset: 0; }
  100% { stroke-dashoffset: -100; }
}

Chỗ dễ hỏng ở đâu? — cái bẫy

Cái bẫy hay gặp nhất nằm ở trục xoay. Chỉ rotate() một <g> hay <path> mà không thêm gì khác, trục quay bị tính theo viewport (0,0) đến (24,24) của cả file SVG chứ không phải theo chính hình dạng đó, nên ở mục 04 và 05 icon từng bị lệch hẳn sang một góc khi quay. Thêm transform-box: fill-box thì trục chuyển về đúng khung bao quanh hình dạng, và thuộc tính này đã chạy ổn định từ Chrome 64, Edge 79, Firefox 55 và Safari 11 (gồm cả iOS) theo bảng caniuse cho transform-box, nên gần như không lo trình duyệt cũ chặn mất. Hai mục 04 và 09 từng đo dưới ngưỡng chuyển động rồi mới sửa như mô tả ở trên, và bộ file mang đúng các giá trị đã sửa đó có mật khẩu giải nén là datzbfgj.

Bẫy thứ hai nằm ở glyph chữ. Gõ thẳng ký tự như ✓ hay → thay cho path SVG thì hình dạng phụ thuộc font hệ điều hành đang cài, có máy không hiện ra gì hoặc hiện sai hình — lý do cả chín mục ở đây đều vẽ bằng path SVG chứ không dùng ký tự font.

Khả năng tiếp cận

Năm nút icon (03, 04, 05, 06, 07) nếu không có tên thì trình đọc màn hình chỉ đọc ra "nút", không biết nút làm gì. Mục 03 và 06 dùng <title> bên trong svg cùng aria-labelledby trên nút, đổi tên ngay khi trạng thái đổi; còn 04, 05, 07 dùng thẳng aria-label cố định, tên không đổi theo trạng thái.

<button aria-labelledby="t3">
  <svg><title id="t3">Phát</title><path d="M8 5l11 7-11 7z"/></svg>
</button>

Bốn mục còn lại (01, 02, 08, 09) là icon trang trí đứng cạnh chữ đã mang sẵn nghĩa, nên svg chỉ cần aria-hidden="true" là đủ.

<svg aria-hidden="true"><path class="badge__star" d="M12 2l2.9 6.9L22 9.3z"/></svg> Tính năng mới

prefers-reduced-motion: reduce, cả chín mục tắt animation và giữ lại hình đã hoàn tất — check đã vẽ xong, tim đã tô đặc — vì tắt chuyển động mà xoá luôn kết quả thì thông tin kiểu "đã lưu chưa" biến mất theo. Các hiệu ứng CSS khác của site nằm trong chuyên mục CSS, còn lý do trang này ra đời thì đọc ở trang giới thiệu.

FAQ

Có thể thay SVG bằng web font icon không?

Được, nhưng web font hay lộ ký tự vỡ hình trong lúc font chưa tải xong, có lúc còn không hiện gì cả. SVG nhúng thẳng trong HTML không cần tải thêm file nào nên mở file trong zip ra là thấy ngay.

Bỏ transform-box: fill-box thì icon xoay sẽ thế nào?

Trục xoay rơi về góc trên trái của viewport SVG thay vì tâm hình dạng, nên với icon không nằm giữa khung như refresh ở mục 05, cả hình sẽ văng lệch sang một bên khi quay thay vì đứng yên tại chỗ xoay tròn.

Năm nút icon quản lý trạng thái trong React ra sao?

Thư mục react/ trong zip dùng useState giữ từng trạng thái playing, open hoặc liked riêng cho mỗi nút, rồi rẽ nhánh className để đổi opacity và thuộc tính aria cùng lúc thay vì toggle class thủ công.

Nhập mật khẩu file nén

Mật khẩu nằm ngay trong bài viết này. Đọc tới đó bạn sẽ thấy.