9 CSS Heart Like Animations — dán 1 dòng là chạy
Chín css heart like animation cho nút thích, đánh giá sao và các nút phản hồi kiểu vỗ tay, bookmark, emoji được gom vào một bài — chỉ đổi transform và opacity,
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Trái tim bung ra
- 02 Pháo hoa hạt nhỏ
- 03 Số lượt thích đổi theo
- 04 Sao đánh giá tô đầy
- 05 Huy hiệu sao lẻ
- 06 Đếm vỗ tay
- 07 Góc bookmark gập mở
- 08 Ngón cái lắc lư
- 09 Chọn emoji cảm xúc
Thứ tự chín mục không theo độ nổi tiếng mà theo loại input nằm phía sau mỗi hiệu ứng. 01 đến 03 và 06 đến 08 đều dùng một checkbox hoặc button tự bật tắt một mình; 04 và 09 dùng một nhóm radio buộc chỉ giữ lại đúng một lựa chọn; riêng 05 không phải input nào cả mà là badge chỉ để xem. Mỗi ô trong lưới phía trên tự phát lại bằng class is-demo cứ hai giây một lần, vì khung iframe không nhận được cú chạm thật của bạn; script trong từng file gỡ đúng class đó ngay khi bạn thật sự bấm, để phần còn lại chạy bằng :checked chứ không phải vòng lặp giả.
01Trái tim bung ra
Bấm vào nhãn thì checkbox ẩn chuyển trạng thái, kéo theo lớp tô màu của trái tim phóng từ tỉ lệ .4 lên 1.15 lần quanh chính tâm rồi lắng lại đúng 1 lần. Trái tim dựng từ hai pseudo-element bo tròn xoay ±45 độ, không dùng ảnh hay thẻ svg nào cả.
.heart__shape--fill {
color: $color;
opacity: 0;
transform: scale(.4) rotate(-8deg);
transition: opacity $duration $easing, transform $duration $easing;
}
.heart__input:checked ~ .heart__icon .heart__shape--fill {
opacity: 1;
transform: scale(1) rotate(0deg);
}
@keyframes heart-pop-loop {
35% { opacity: 1; transform: scale(1.15) rotate(6deg); }
55%, 80% { opacity: 1; transform: scale(1) rotate(0deg); }
}
02Pháo hoa hạt nhỏ
Ngay khi checkbox được chọn, tám chấm quanh trái tim toả ra theo góc riêng của từng chấm — góc đó tính từ custom property --i nhân với 45deg — rồi mờ dần và thu nhỏ khi bay ra xa. Không có ảnh nào ở đây cả, chỉ tám span tròn dùng chung một keyframes.
.burst__dot {
position: absolute; top: 50%; left: 50%;
width: 5px; height: 5px; margin: -2.5px 0 0 -2.5px;
border-radius: 50%; background: $color;
transform: rotate(calc(var(--i) * 45deg)) translateY(0) scale(1);
}
.burst__input:checked ~ .burst__icon .burst__dot {
animation: particle-fly $duration $easing forwards;
animation-delay: calc(var(--i) * 20ms);
}
@keyframes particle-fly {
100% { opacity: 0; transform: rotate(calc(var(--i) * 45deg)) translateY(-34px) scale(.2); }
}
03Số lượt thích đổi theo
CSS không tự đổi được chữ số, nên một đoạn script ngắn nghe sự kiện change của checkbox, ghi lại "13" hoặc "12" vào span rồi bật class is-pop để con số vừa đổi nảy lên bằng scale trong chớp mắt.
function sync() {
num.textContent = inp.checked ? "13" : "12";
num.classList.remove("is-pop");
void num.offsetWidth;
num.classList.add("is-pop");
}
inp.addEventListener("change", 갱신);
04Sao đánh giá tô đầy
Năm radio xếp ngược 5·4·3·2·1 rồi lật bằng row-reverse để thứ tự hiển thị trên màn hình đúng từ 1 đến 5, nên sibling selector ~ tô màu mọi sao đứng sau radio vừa chọn trong DOM — tức mọi sao thấp hơn hoặc bằng điểm đó. Riêng sao vừa chọn còn nảy thêm một nhịp scale để xác nhận cú bấm.
.rating__stars { display: flex; flex-direction: row-reverse; }
.rating__input:checked ~ .rating__star { color: $color; }
.rating__input:checked + .rating__star { animation: star-pick $duration $easing; }
@keyframes star-pick {
45% { transform: scale(1.35); }
100% { transform: scale(1); }
}
05Huy hiệu sao lẻ
Đây không phải input mà là badge điểm trung bình chỉ để xem, dựng từ hai bộ sao chồng nhau: bộ nền mờ ở dưới và bộ tô màu đặt chồng lên trên, dùng overflow: hidden để chỉ lộ ra đúng phần trăm điểm. Cứ mỗi hai giây, phần trăm đó lại chạy từ 0 lên giá trị thật, giữ nguyên một lúc rồi lặp lại từ đầu — badge này không có cú chạm nào để dừng lại, nên vòng lặp không tắt hẳn.
.avg__fill {
position: absolute; inset: 0;
width: 0%; overflow: hidden; color: $color;
}
@keyframes reveal-fill {
55%, 80% { width: var(--pct); }
100% { width: var(--pct); }
}
.avg__fill { animation: reveal-fill $dur-loop $easing infinite; }
06Đếm vỗ tay
Mỗi lần bấm, icon 👏 nảy nhẹ bằng scale trong lúc chữ "+1" trôi lên bằng translateY rồi mờ dần qua opacity, còn con số bên dưới tăng thêm 1 qua vài dòng JS. Bấm liên tục thì animation chỉ chạy lại chứ không cộng dồn chồng lên nhau.
@keyframes clap-bounce {
40% { transform: scale(1.3) rotate(-12deg); }
100% { transform: scale(1) rotate(0deg); }
}
@keyframes clap-plus {
0% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(-16px); }
}
07Góc bookmark gập mở
Check vào thì ruy băng bookmark tô đầy dần từ trên xuống bằng scaleY, đồng thời một tam giác nhỏ ở góc trên phải xoay từ -90 độ về 0 độ quanh chính góc đó — nhờ transform-origin đặt đúng vào góc chứ không phải tâm — nên trông như một mẩu giấy vừa được gập mở ra.
.bookmark__fold {
position: absolute; top: 0; right: 0; width: 8px; height: 8px;
clip-path: polygon(100% 0, 0 0, 100% 100%);
background: rgba(23, 20, 26, .35);
transform: scale(0) rotate(-90deg);
transform-origin: 100% 0;
opacity: 0;
transition: opacity $duration $easing, transform $duration $easing;
}
.bookmark__input:checked ~ .bookmark__icon .bookmark__fold {
opacity: 1; transform: scale(1) rotate(0deg);
}
08Ngón cái lắc lư
Trước khi check, icon ngón cái nằm im dưới filter grayscale. Check vào thì màu sống lại đồng thời icon xoay từ -20 độ qua 5 độ rồi mới về hẳn 0 độ, còn scale phồng lên rồi xẹp theo đúng ba mốc đó để giống một cái vẫy tay thật hơn là một vòng xoay đều.
.thumb__icon {
filter: grayscale(1) opacity(.55);
transition: filter $duration $easing;
}
.thumb__input:checked ~ .thumb__icon {
filter: grayscale(0) opacity(1);
animation: thumb-bounce $duration $easing;
}
@keyframes thumb-bounce {
30% { transform: scale(1.25) rotate(-20deg); }
60% { transform: scale(1.1) rotate(5deg); }
}
09Chọn emoji cảm xúc
Bốn emoji nằm trong một nhóm radio. Selector :has() kiểm tra cả nhóm có radio nào được chọn chưa — nếu có, mọi emoji đồng loạt mờ và nhỏ lại — rồi riêng emoji vừa chọn được tô đậm bằng rule sibling + ngay sau input đó, phóng to hẳn lên so với phần còn lại.
.react__emoji {
transition: opacity $duration $easing, transform $duration $easing;
}
.react:has(.react__input:checked) .react__emoji {
opacity: .4; transform: scale(.9);
}
.react__input:checked + .react__emoji {
opacity: 1; transform: scale(1.3);
}
Ở đâu dễ vỡ — cái bẫy custom property không chịu chuyển động mượt
Custom property CSS (giá trị bắt đầu bằng --, như --rating ở mục 05) không tự được trình duyệt nội suy khi đặt thẳng nó vào keyframes — thiếu khai báo kiểu, trình duyệt chỉ biết nhảy thẳng từ giá trị đầu sang giá trị cuối chứ không tính các bước ở giữa. Bản dựng đầu tiên của mục 05 từng animate trực tiếp --fill từ 0% lên var(--pct), nên chuyển động nhảy thẳng từ đầu sang cuối chỉ trong đúng một khung hình duy nhất — sao bật sáng cái rụp thay vì chảy dần lên. Trang @property của MDN giải thích custom property cần đăng ký kiểu (<percentage> hoặc <length> chẳng hạn) thì trình duyệt mới nội suy được nó. Không muốn đăng ký kiểu thì đường vòng là animate một thuộc tính chuẩn vốn đã nội suy sẵn — ở đây là width — và gán cho nó calc(var(--rating) / 5 * 100%); mục 05 hiện dùng đúng cách này nên phần chảy lên chia đều ra nhiều khung hình chứ không dừng ở một cú nhảy.
Bẫy thứ hai nằm ở mục 09: nó dựa vào :has() để biết cả nhóm radio có lựa chọn nào chưa. Theo bảng hỗ trợ trên caniuse, :has() mới chạy từ Chrome/Edge 105+, Safari 15.4+ và Firefox 121+ — dưới các mốc đó, cú mờ đi đồng loạt của mục 09 sẽ không xảy ra, chỉ còn emoji được chọn tự phóng to mà thôi. Mục 04 không đụng đến :has() — nó chỉ dùng sibling selector ~, vốn chạy được trên trình duyệt từ rất lâu trước đó, nên phần tô sao của mục này không bị ảnh hưởng. Coi :has() như một lớp tô điểm thêm chứ đừng để cả tính năng phụ thuộc vào nó. Chín file vanilla trong bản nén không có đoạn script tự đảo trạng thái hai giây một lần như lưới demo phía trên, mật khẩu để mở là 8ayu345c, gõ đúng y như nó xuất hiện ở đây.
Khả năng tiếp cận
Bảy mục dùng checkbox hoặc radio ẩn (01, 02, 03, 04, 07, 08, 09) vẫn nằm trong luồng focus bình thường vì chỉ bị ẩn bằng kỹ thuật thu nhỏ còn 1px, nên phím Tab và phím cách vẫn đổi được trạng thái mà không cần chuột. Mục 04 bọc năm radio trong fieldset cùng legend ẩn kiểu sr-only để trình đọc màn hình biết đây là một nhóm đánh giá, và mỗi label sao còn có aria-label riêng đọc thành "N điểm". Mục 06 dùng aria-live="polite" để số vỗ tay mới được đọc ngay khi tăng, còn mục 09 gắn aria-label cho từng emoji để tên cảm xúc được đọc thay vì chỉ đọc ký tự emoji thô. Dưới prefers-reduced-motion: reduce, tất cả animation và transition rút còn gần như tức thời — trạng thái cuối hiện ra ngay, không phần nào bị mất hẳn khả năng đọc hay bấm. Trang prefers-reduced-motion của MDN ghi rõ mức hỗ trợ trình duyệt cho media query này.
Muốn đổi checkbox thành <button aria-pressed="true"> cũng được, miễn script tự cập nhật aria-pressed mỗi lần trạng thái đổi:
<fieldset>
<legend>Đánh giá</legend>
<button type="button" class="rating__star" aria-pressed="false">★</button>
</fieldset>
Xem thêm các bài CSS khác trong chuyên mục CSS, còn trang giới thiệu nói rõ vì sao mỗi bài ở đây đi kèm một file zip có mật khẩu thay vì dán thẳng code.
FAQ
Checkbox ẩn để đổi giao diện có phải mẹo hack không?
Không hẳn là hack — đây là cách làm được W3C khuyến nghị khi cần vẽ lại control gốc: input thật vẫn ở đó và vẫn nhận focus, chỉ có style bên ngoài thay hẳn diện mạo mặc định của trình duyệt. Cách này khác hẳn display: none, vốn gỡ input ra khỏi cả luồng focus lẫn cây accessibility.
Chín hiệu ứng này có animate trực tiếp màu nền hay không?
Có một ngoại lệ: mục 03 animate thẳng background để đổi màu trái tim khi checkbox bật. Mục 08 né hẳn việc đổi màu nền, chuyển màu qua filter thay vì background. Sáu mục còn lại chỉ dùng opacity, transform hoặc width — đúng nhóm thuộc tính trình duyệt chỉ cần vẽ lại lớp compositor mà không phải tính lại layout, nên khung hình mượt hơn animate màu trực tiếp; mục 03 là chỗ đánh đổi hiệu năng nhỏ để đổi màu tức thì.
Bản react/ trong zip quản lý trạng thái thế nào?
Mỗi component giữ trạng thái checked hoặc rating bằng useState và đẩy giá trị lên cha qua onChange. Riêng mục 05 không nhận input nào cả, nó là component chỉ đọc, nhận đúng một prop rating rồi tự tính phần trăm tô như bản vanilla.