GODRICH
#css-animation#button#loader#transition#scroll#ui#xu-huong-thiet-ke

9 css loading animation cho AI chat, copy-paste

Ba chấm nhấp nhô, con trỏ chớp tắt, chữ hiện dần lúc bạn chờ câu trả lời trong khung chat AI như ChatGPT hay Claude đều là css loading animation.

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

Chín kiểu xếp theo trình tự diễn ra trong một khung chat AI chứ không theo độ nổi tiếng: tín hiệu chờ lúc đợi câu trả lời (01~03), lúc câu trả lời tới và hội thoại chất dần lên (04~05), quá trình AI gọi công cụ cùng cách chữ nối nhau hiện ra (06~07), khép lại ở thao tác dừng sinh nội dung và bảng thay thế cho người đã giảm chuyển động (08~09).

01Ba chấm đang nghĩ

Trên màn hình

Gửi tin xong, trong lúc AI soạn câu trả lời, ba chấm nhỏ trong bong bóng trống to lên rồi nhỏ lại lần lượt như người đang gõ phím.

Ba chấm đang suy nghĩ
Mở trong tab mới

01 · Ba chấm đang suy nghĩ — Diện tích đo được 0.225% · khung hình chuyển động 21/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scale@keyframes1.2s

Cách tạo

@keyframes td-bounce {
  0%, 60%, 100% { transform: translateY(0); opacity: .35; }
  30% { transform: translateY(-6px); opacity: 1; }
}
.td-bubble.is-loading .td-dot,
.td-bubble.is-demo .td-dot {
  animation: td-bounce $duration $easing infinite;
}
.td-bubble.is-demo .td-dot:nth-child(2) { animation-delay: 160ms; }
.td-bubble.is-demo .td-dot:nth-child(3) { animation-delay: 320ms; }

02Chữ Thinking loang sáng

Nhìn thế nào

Với việc tốn thời gian như tìm kiếm hay tóm tắt, thay cho chấm là một dòng trạng thái kèm dải sáng lướt qua mặt chữ để báo vẫn đang chạy.

Chữ trạng thái shimmer
Mở trong tab mới

02 · Chữ trạng thái shimmer — Diện tích đo được 0.287% · khung hình chuyển động 17/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

background-positionbackground-clip: text1.6s

Cách đọc code

.sh-label {
  background-image: linear-gradient(
    90deg,
    rgba(23, 20, 26, .3) 0%,
    rgba(23, 20, 26, .3) 42%,
    $color 50%,
    rgba(23, 20, 26, .3) 58%,
    rgba(23, 20, 26, .3) 100%
  );
  background-size: 220% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: rgba(23, 20, 26, .3);
}

03Con trỏ streaming chớp tắt

Typing indicator animation css dùng ở đâu?

Khi chữ trả lời nối nhau hiện realtime, gắn con trỏ ngắn ở cuối câu rồi cho chớp bằng opacity là đủ báo chữ tiếp theo vẫn về đúng chỗ đó.

Con trỏ chớp khi stream
Mở trong tab mới

03 · Con trỏ chớp khi stream — Diện tích đo được 0.272% · khung hình chuyển động 7/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

opacitysteps()530ms

Điểm chính của code

.cr-caret {
  display: inline-block;
  width: $sp-2;
  height: 1em;
  margin-left: $sp-1;
  vertical-align: -0.2em;
  background: $color;
}

@keyframes cr-blink {
  0%, 49%  { opacity: 1; }
  50%, 100% { opacity: .25; }
}
.cr-line.is-demo .cr-caret,
.cr-line.is-demo .cr-tail {
  animation: cr-blink $duration $easing infinite;
}

04Bong bóng tin hiện lên

Màn hình nào

Câu trả lời soạn xong, bong bóng ở chỗ trống phình nhẹ rồi thu về kích thước thật, báo rõ cho bạn biết câu trả lời vừa tới.

Bong bóng tin nhắn bật vào
Mở trong tab mới

04 · Bong bóng tin nhắn bật vào — Diện tích đo được 13.303% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scaleopacitycubic-bezier

Nguyên lý hoạt động

@keyframes bp-pop {
  0%   { transform: scale(.82); opacity: 0; }
  15%  { transform: scale(1.05); opacity: 1; }
  25%  { transform: scale(1); opacity: 1; }
  70%  { transform: scale(1); opacity: 1; }
  85%  { transform: scale(.9); opacity: 0; }
  100% { transform: scale(.82); opacity: 0; }
}
.bp-bubble.is-demo {
  animation: bp-pop $duration $easing infinite;
}

05Mờ dần ở mép vùng cuộn

Cái gì chuyển động

Tin nhắn chất lên làm khung chat sinh thanh cuộn, hai mép trên dưới được xóa nhạt để báo còn nội dung mà không cắt ngang chữ.

Mờ dần hai mé cuộn
Mở trong tab mới

05 · Mờ dần hai mé cuộn — Diện tích đo được 27.117% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

mask-imagetranslateYoverflow: hidden

Cách làm

.sf-viewport {
  overflow: hidden;
  // Mờ dần ở mép — lớp mask này đứng yên. Chỉ track bên trong mới chạy
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 $sp-8, #000 calc(100% - #{$sp-8}), transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0, #000 $sp-8, #000 calc(100% - #{$sp-8}), transparent 100%);
  padding: $sp-4 0;
}

Thứ chuyển động không phải lớp mask mà là danh sách bên trong nó. Nối liền hai bản cùng một danh sách tin nhắn rồi đẩy đi nửa quãng bằng translateY(-50%), tới điểm giữa bản sau rơi đúng vào chỗ cũ nên nhìn như dòng chảy liên tục không thấy mối nối.

06Bày các bước gọi công cụ

Dùng ở đâu

Khi AI dùng công cụ theo thứ tự như tìm rồi đọc tài liệu, các bước xếp dọc, bước nào xong thì vòng tròn đổi thành dấu check và đường nối đổ đầy xuống dưới.

Tiến trình bước gọi công cụ
Mở trong tab mới

06 · Tiến trình bước gọi công cụ — Diện tích đo được 0.112% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scaleYstroke-dashoffsetJS 6줄

Lý do làm vậy

.ts-line {
  grid-column: 1;
  justify-self: center;
  width: 2px;
  height: $sp-6;
  background: rgba(23, 20, 26, .18);
  transform-origin: top;
  transform: scaleY(0);
}

@keyframes ts-line-fill { 0%, 5% { transform: scaleY(0); } 25%, 100% { transform: scaleY(1); } }
@keyframes ts-check-2   { 0%, 25% { stroke-dashoffset: 16; } 40%, 100% { stroke-dashoffset: 0; } }

.ts-list.is-demo .ts-line--1      { animation: ts-line-fill $duration $easing infinite; }
.ts-list.is-demo .ts-icon--2 path { animation: ts-check-2 $duration $easing infinite; }

Trên màn hình thật, mỗi lần có sự kiện công cụ về thì một đoạn javascript ngắn gắn class .is-done để cùng đoạn CSS đó chạy đúng một lần.

function tsAdvance(list, n) {
  var steps = list.querySelectorAll('.ts-step');
  var lines = list.querySelectorAll('.ts-line');
  for (var i = 0; i < n; i++) steps[i].classList.add('is-done');
  for (var i = 0; i < n - 1; i++) lines[i].classList.add('is-done');
}

07Chữ hiện dần theo từng từ

Điều bạn thấy

Thay vì để nguyên câu bật ra một lượt, cho từng từ nổi lên lần lượt từ dưới lên là ra cảm giác streaming, như token đang nối nhau realtime.

Fade-in từng từ
Mở trong tab mới

07 · Fade-in từng từ — Diện tích đo được 2.202% · khung hình chuyển động 16/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

translateYanimation-delaynth-child

Cốt lõi khi triển khai

@keyframes wf-word {
  0%, 100% { opacity: 0; transform: translateY(6px); }
  15%, 78% { opacity: 1; transform: translateY(0); }
  92%      { opacity: 0; transform: translateY(6px); }
}
.wf-line.is-demo span {
  animation: wf-word $duration $easing infinite;
}
.wf-line.is-demo span:nth-child(1) { animation-delay: 0ms; }
.wf-line.is-demo span:nth-child(2) { animation-delay: 90ms; }
.wf-line.is-demo span:nth-child(3) { animation-delay: 180ms; }

08Nút gửi biến thành nút dừng

Tình huống nào

Bấm gửi thì icon máy bay giấy nhạt đi và co lại, ngay chỗ đó hình vuông dừng chồng lên hiện ra, dáng nút giữ nguyên mà chỉ icon bên trong đổi.

Nút gửi hóa nút dừng
Mở trong tab mới

08 · Nút gửi hóa nút dừng — Diện tích đo được 0.176% · khung hình chuyển động 10/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

scaleopacity200ms

Điểm mấu chốt

@keyframes sm-send {
  0%, 12%  { opacity: 1; transform: scale(1); }
  30%, 62% { opacity: 0; transform: scale(.5); }
  80%, 100% { opacity: 1; transform: scale(1); }
}
@keyframes sm-stop {
  0%, 12%  { opacity: 0; transform: scale(.5); }
  30%, 62% { opacity: 1; transform: scale(1); }
  80%, 100% { opacity: 0; transform: scale(.5); }
}

09Badge tĩnh thay cho chấm đập

Chuyện gì xảy ra

Bình thường chấm trạng thái đập nhẹ bằng box-shadow với scale, nhưng với người bật giảm chuyển động thì riêng animation của badge tắt đi, giữ nguyên chỗ cũ ở trạng thái tĩnh.

Badge tĩnh cho prefers-reduced-motion
Mở trong tab mới

09 · Badge tĩnh cho prefers-reduced-motion — Diện tích đo được 0.278% · khung hình chuyển động 19/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu

box-shadowprefers-reduced-motionscale

Điểm cần ghi nhớ

@keyframes rb-pulse {
  0%   { box-shadow: 0 0 0 0 rgba(47, 109, 246, .55); transform: scale(1); }
  70%  { box-shadow: 0 0 0 8px rgba(47, 109, 246, 0); transform: scale(1.15); }
  100% { box-shadow: 0 0 0 0 rgba(47, 109, 246, 0); transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .rb-badge.is-demo .rb-dot {
    animation: none !important;
    box-shadow: none !important;
    transform: none !important;
  }
}

Loading animation cho chat AI dễ vỡ ở đâu

Vấn đề là gì?

Khác với thanh loading có đích rõ ràng, mục 05 không định trước điểm kết mới là chỗ hay vấp nhất. Vòng lặp vô hạn translateY(-50%) chỉ chảy liền mạch khi danh sách được nối đúng hai bản; thêm phần tử vào một bên hoặc chèn gap giữa hai danh sách là điểm giữa lệch đi, mỗi vòng lại giật lên thấy rõ. Con trỏ ở mục 03 cũng vậy: lấy width chạy theo bề ngang chữ thật thì bố cục bị tính lại từng khung hình và sinh khựng, nên cả chín mục trong bài chỉ động tới transform với opacity, riêng con trỏ chọn cách chớp một khối cố định bề ngang.

Tránh thế nào?

Đường nối ở mục 06 cũng phải gắn scaleY vào transform-origin: top chứ không đụng height thì mới không đẩy các phần tử khác. Mật khẩu dùng mở file nén này nằm ngay chỗ có dấu 2r7u46js, viết bằng chữ bình thường trong câu chứ không đóng khung gì. Safari bản cũ bắt buộc phải có tiền tố -webkit- cho mask-image thì mục 05 mới hiện, nên bạn chú ý đừng xóa tiền tố đó.

Tiếp cận — chín mục giữ lại gì dưới reduced-motion

Dưới reduced-motion còn lại gì?

Bật giảm chuyển động trong hệ thống thì cả chín mục tắt animation trong prefers-reduced-motion: reduce. Những tín hiệu trang trí như 01, 02, 03, 04, 05, 08 chỉ cần đứng ở trạng thái cuối là đủ; nhưng bảng bước ở 06 và thứ tự từ ở 07 truyền thông tin bằng chính chuyển động, nên đường kẻ với các từ được ghim thẳng vào trạng thái hoàn tất để không động đậy vẫn nắm được tình hình. Mục 09 thể hiện nguyên tắc này rõ nhất: nó chuẩn bị sẵn một badge tĩnh thay cho chấm đang đập.

Bài nên đọc tiếp

Nếu bạn tìm màn hình chờ dạng khung xương thay vì trạng thái loading thì có bài 9 kiểu skeleton UI CSS, còn muốn xem riêng nhóm spinner xoay thì vào 9 css loading spinner.

FAQ — hỏi đáp nhanh

Ngoài css loading spinner thì nên dùng cái nào trước?

Thời gian chờ quanh một giây thì ba chấm mục 01 hay vệt sáng mục 02 là đủ. Nếu đúng là phản hồi streaming với token về từng cái một, con trỏ mục 03 hoặc css loading animation dots dạng chữ hiện dần ở mục 07 sẽ khiến người dùng yên tâm hơn vì thấy được tiến độ.

Không dùng icon, chỉ CSS thuần có làm được không?

Mục 01, 02, 03, 04, 05, 07, 09 chạy được chỉ với CSS thuần và biến SCSS. Mục 06 dùng thêm một SVG nhỏ cho icon check, còn mục 06 với 08 trong dịch vụ thật cần vài dòng javascript gắn class mỗi khi đổi trạng thái.

Code vanilla với React trong zip có khác nhau không?

Nhìn bề ngoài thì giống, chỉ khác đường truyền giá trị. Bản vanilla phải sửa ba biến ở đầu file SCSS, còn bản React nhận duration, easing, color qua props của component rồi tự đổi thành CSS custom property bên trong để dựng cùng một màn hình.

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.