9 CSS Chat Bubble Animation — dán 1 dòng là chạy
CSS chat bubble animation là hiệu ứng bong bóng chat chạy bằng CSS thuần khi tin nhắn xuất hiện hoặc được xem.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Bong bóng bật hiện (pop-in)
- 02 Đổi hướng đuôi bong bóng
- 03 Ba chấm đang gõ
- 04 Chuyển trạng thái đã xem
- 05 Bong bóng ảnh đang tải
- 06 Trượt trích dẫn trả lời
- 07 Emoji react bật lên
- 08 Vạch ngày xuất hiện
- 09 Ghim tin nhắn mới ở đáy
Thứ tự chín kiểu này đi theo vòng đời một tin nhắn trong khung chat: bong bóng vừa đến và có hướng gửi hay nhận (01–02), báo hiệu đối phương đang gõ (03), chuyển trạng thái đã xem và tải xong ảnh đính kèm (04–05), gắn ngữ cảnh trả lời (06), nhận phản ứng emoji (07), rồi khung chat tự chia theo ngày và ghim tin mới ở đáy (08–09). Khung xem phía trên là một iframe cố định không nhận sự kiện chuột thật, nên mỗi ô tự phát lại đúng một vòng hai giây qua lớp .is-demo gắn riêng cho bản xem trước; bản gốc trong từng file không có lớp đó.
01Bong bóng bật hiện (pop-in)
Bong bóng tin nhắn mới phóng to từ scale(.4) lên scale(1.15) rồi lắng xuống đúng kích thước, đồng thời opacity chuyển từ 0 sang 1 trong một chuyển động duy nhất. Dùng cho tin nhắn mới trong chat 1:1 hoặc bong bóng xem trước bên trong toast thông báo, nơi cần "vừa tới" hiện rõ ngay lập tức.
.bubble {
transform-origin: center;
&.is-demo {
animation: bubble-pop-loop $dur-loop $easing infinite;
}
}
@keyframes bubble-pop-loop {
0% { transform: scale(.4); opacity: 0; }
16% { transform: scale(1.15); opacity: 1; }
24% { transform: scale(1); opacity: 1; }
82% { transform: scale(1); opacity: 1; }
100% { transform: scale(.4); opacity: 0; }
}
02Đổi hướng đuôi bong bóng
Hai tam giác đuôi được vẽ sẵn bằng clip-path ở hai bên, còn bong bóng dịch chuyển bằng translateX sang trái hoặc phải trong khi opacity của từng tam giác bật tắt xen kẽ để chỉ một đuôi hiện ra đúng phía. Kiểu này hợp cho chat 1:1 cần phân biệt tin gửi đi và tin nhận về, hoặc bong bóng trong một luồng chat nhóm.
.tail--left { left: -6px; clip-path: polygon(100% 0, 100% 100%, 0 50%); }
.tail--right { right: -6px; clip-path: polygon(0 0, 0 100%, 100% 50%); }
@keyframes tail-shift-loop {
0% { transform: translateX(-70px); }
32% { transform: translateX(-70px); }
68% { transform: translateX(70px); }
100% { transform: translateX(70px); }
}
03Ba chấm đang gõ
Ba chấm tròn dùng chung một @keyframes nảy lên theo translateY, nhưng mỗi chấm mang animation-delay âm lệch nhau 200ms nên trông như một đợt sóng chạy từ trái sang phải chứ không nảy đồng loạt. Đây là chỉ báo quen thuộc cho trạng thái đối phương đang gõ trong messenger, hoặc lúc chatbot đang chờ tạo phản hồi.
.dot {
animation: dot-bounce-loop $dur-loop $easing infinite;
&:nth-child(1) { animation-delay: 0s; }
&:nth-child(2) { animation-delay: -.2s; }
&:nth-child(3) { animation-delay: -.4s; }
}
@keyframes dot-bounce-loop {
0% { transform: translateY(0); opacity: .4; }
16% { transform: translateY(-9px); opacity: 1; }
32% { transform: translateY(0); opacity: .4; }
100% { transform: translateY(0); opacity: .4; }
}
04Chuyển trạng thái đã xem
Số chưa đọc và dấu tick dùng hai @keyframes riêng chạy song song trong cùng một khung thời gian: số cũ co nhỏ dần về opacity: 0 đúng lúc dấu tick bật lên quá cỡ rồi lắng về kích thước thật. Chuyển động này xác nhận tin đã được xem trong chat 1:1, hoặc báo nhân viên hỗ trợ đã trả lời trong một khung chat tư vấn.
.row.is-demo .unread { animation: unread-out-loop $dur-loop $easing infinite; }
.row.is-demo .check { animation: check-in-loop $dur-loop $easing infinite; }
@keyframes check-in-loop {
0% { opacity: 0; transform: scale(.4); }
48% { opacity: 0; transform: scale(.4); }
62% { opacity: 1; transform: scale(1.15); }
72% { opacity: 1; transform: scale(1); }
100% { opacity: 1; transform: scale(1); }
}
05Bong bóng ảnh đang tải
Một dải sáng quét ngang qua lớp skeleton xám bằng translateX, rồi tắt hẳn đúng lúc lớp ảnh thật chuyển opacity từ 0 sang 1 để thế chỗ. Kiểu tải này hợp với chat trao đổi ảnh hoặc GIF, và với khung chat hỏi hàng có ảnh sản phẩm đính kèm.
.shine { animation: shine-loop $dur-loop $easing infinite; }
.photo { opacity: 0; animation: photo-in-loop $dur-loop $easing infinite; }
@keyframes shine-loop {
0% { transform: translateX(0); }
60% { transform: translateX(360%); }
100% { transform: translateX(360%); }
}
@keyframes photo-in-loop {
0% { opacity: 0; }
62% { opacity: 0; }
85% { opacity: 1; }
100% { opacity: 1; }
}
06Trượt trích dẫn trả lời
Đoạn trích dẫn nằm phía trên tin nhắn trả lời, trượt vào từ bên trái bằng translateX(-18px) về 0 trong khi opacity tăng dần, gắn chặt vào đúng dòng đang được phản hồi. Cách này hợp cho chat dạng thread trả lời một tin nhắn cụ thể, hoặc phần trả lời review và bình luận.
.quote {
border-left: 3px solid $subject-cream;
&.is-demo {
animation: quote-in-loop $dur-loop $easing infinite;
}
}
@keyframes quote-in-loop {
0% { transform: translateX(-18px); opacity: 0; }
22% { transform: translateX(0); opacity: 1; }
80% { transform: translateX(0); opacity: 1; }
95% { transform: translateX(-18px); opacity: 0; }
100% { transform: translateX(-18px); opacity: 0; }
}
07Emoji react bật lên
Huy hiệu tròn chứa emoji bật từ scale(0) vượt quá cỡ tới scale(1.3) rồi lắng về scale(1), đúng kiểu bounce, nhấn mạnh khoảnh khắc vừa được tap. Dùng cho chat cho phép react emoji vào tin nhắn, hoặc nút react trên một bài đăng cộng đồng.
.wrap.is-demo .reaction {
animation: reaction-pop-loop $dur-loop $easing infinite;
}
@keyframes reaction-pop-loop {
0% { transform: scale(0); opacity: 0; }
55% { transform: scale(0); opacity: 0; }
70% { transform: scale(1.3); opacity: 1; }
80% { transform: scale(1); opacity: 1; }
96% { transform: scale(1); opacity: 1; }
100% { transform: scale(0); opacity: 0; }
}
08Vạch ngày xuất hiện
Viên nang ngày tháng hạ xuống nhẹ bằng translateY(-14px) về 0 kèm opacity, rồi ở yên giữa danh sách tin nhắn cho tới khi khung chat cuộn qua đoạn đó. Dùng khi cuộn qua một khung chat kéo dài nhiều ngày, hoặc danh sách thông báo được nhóm theo ngày.
.divider.is-demo {
animation: divider-in-loop $dur-loop $easing infinite;
}
@keyframes divider-in-loop {
0% { transform: translateY(-14px) scale(.9); opacity: 0; }
20% { transform: translateY(0) scale(1); opacity: 1; }
82% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-14px) scale(.9); opacity: 0; }
}
09Ghim tin nhắn mới ở đáy
Tin nhắn mới trượt lên từ đáy bằng translateY(46px) về 0 và opacity tăng dần, nhưng chuyển động này chỉ nên chạy khi khung chat đã cuộn sát đáy sẵn từ trước. Hợp cho phòng chat thời gian thực nhận tin liên tục, hoặc khung chat của một buổi livestream.
.bubble--out.is-demo {
transform-origin: bottom center;
animation: pin-in-loop $dur-loop $easing infinite;
}
@keyframes pin-in-loop {
0% { transform: translateY(46px); opacity: 0; }
28% { transform: translateY(0); opacity: 1; }
84% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(46px); opacity: 0; }
}
Lớp .is-demo dễ vỡ ở đâu khi đem vào sản phẩm thật? — cái bẫy
Trong chín kiểu này, cái dễ vỡ nhất không nằm ở hiệu ứng nào cả mà ở chính lớp .is-demo dùng để quay gif. Trong mỗi file, hoạt ảnh chỉ được gắn dưới dạng &.is-demo { animation: bubble-pop-loop $dur-loop $easing infinite; }, và từ khoá infinite ở đó tồn tại vì bản xem trước phải lặp lại liên tục để gif ghi được nhiều vòng. Bê nguyên khối này — cả từ khoá infinite lẫn tên lớp .is-demo — vào một ứng dụng chat thật thì bong bóng, ba chấm gõ hay huy hiệu react sẽ hiện rồi biến mất lặp đi lặp lại mãi đúng như trong bản xem trước, thay vì chạy đúng một lần khi tin nhắn vừa được thêm vào khung chat. Một hoạt ảnh CSS gắn sẵn trên một phần tử sẽ tự chạy ngay khi trình duyệt vẽ phần tử đó lần đầu — không cần :hover, không cần JavaScript bật thêm lớp nào — miễn là animation-iteration-count không phải infinite và animation-fill-mode được đặt both để giữ nguyên trạng thái cuối, đúng như tài liệu animation-fill-mode của MDN mô tả. Bản trong file nén đã đổi sẵn .is-demo thành lớp chạy đúng một lần cho cả chín kiểu, và mật khẩu để mở file đó là e469fwnd, gõ đúng như nó hiện trên dòng này, không thêm khoảng trắng nào.
Khả năng tiếp cận
Bật "giảm chuyển động" trong hệ điều hành thì cả chín kiểu đều tắt animation và giữ nguyên trạng thái đã hoàn tất thay vì ẩn hẳn nội dung: bong bóng 01 đứng yên ở kích thước đầy đủ, đuôi 02 nằm cố định về phía đã gửi hoặc đã nhận, hàng chấm 03 chỉ còn mờ nhạt không nảy, tick đã xem ở 04 hiện sẵn, ảnh ở 05 hiện đủ không qua bước quét sáng, trích dẫn 06 và huy hiệu react 07 hiện ngay tại chỗ, còn vạch ngày 08 và tin nhắn ghim 09 cũng đứng yên ở vị trí cuối. Không kiểu nào biến mất hoàn toàn khi tắt chuyển động — quy tắc chung ở đây luôn là bỏ animation và giữ trạng thái cuối, đúng tinh thần mà trang prefers-reduced-motion của MDN mô tả.
Xem thêm các hiệu ứng 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 đều đóng gói thành một file zip có khoá riêng.
FAQ
Cần bao nhiêu dòng JavaScript để chạy chín hiệu ứng này?
Không cần dòng nào. Cả chín kiểu chỉ dùng CSS animation và keyframes thuần; lớp .is-demo trong bản xem trước chỉ tồn tại để ép hoạt ảnh lặp lại cho gif, còn bản trong file nén tự chạy đúng một lần khi phần tử được thêm vào DOM.
Có ghép được vào React hoặc một app chat thật không?
Ghép được. Thư mục react/ trong file nén có sẵn một component cho mỗi kiểu, nhận thời lượng và màu qua props, còn phần bong bóng, đuôi, chấm gõ vẫn là các phần tử HTML thường nên gắn thẳng vào danh sách tin nhắn của Messenger, Zalo hay bất kỳ giao diện chat nào bạn đang dựng.
Chín kiểu này có nặng cho hiệu năng khi nhiều bong bóng xuất hiện liên tiếp không?
Không nặng. Toàn bộ chín kiểu chỉ animate transform và opacity, hai thuộc tính trình duyệt xử lý ở bước hợp thành (compositing) mà không phải tính lại layout ở mỗi khung hình, nên vẫn mượt kể cả khi một loạt tin nhắn đổ về cùng lúc trong một khung chat dài.