9 hiệu ứng css loading spinner animation gọn nhẹ
CSS loading spinner animation là một khối nhỏ tự xoay, tự nảy hoặc tự đổi độ sáng bằng thuần CSS để báo cho người dùng biết trang vẫn đang xử lý mà không
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Spinner vòng tròn cơ bản
- 02 Vòng đôi xoay ngược chiều
- 03 3 chấm nảy
- 04 Thanh vô định
- 05 Quét conic-gradient
- 06 Vòng tròn stroke-dashoffset
- 07 Vòng % với @property
- 08 Loading ngay trong nút
- 09 Quả cầu pulse kiểu app AI
Thứ tự chín kiểu đi từ đơn giản nhất đến phức tạp nhất về mặt kỹ thuật CSS. Từ 01 đến 04, mỗi kiểu chỉ xoay hoặc dịch chuyển một hình khối bằng transform, không có thuộc tính CSS nào lạ mắt cả. Từ 05 đến 07 mượn ba kỹ thuật vẽ khác nhau — conic-gradient, SVG stroke-dashoffset và @property — để có đường viền mượt hơn hoặc biết đúng bao nhiêu phần trăm đã xong. Bài 08 nhét một spinner giống bài 01 vào bên trong một nút bấm thật, còn bài 09 là kiểu quả cầu mà nhiều app AI dùng để gợi ý rằng nó đang suy nghĩ câu trả lời. Lưới demo phía trên chỉ minh hoạ dáng chuyển động của từng kiểu; toàn bộ mã không cắt dòng nằm trong file zip.
01Spinner vòng tròn cơ bản
Viền tròn dày 6px giữ nguyên chỗ, chỉ cạnh trên đổi màu, và toàn bộ khối tự xoay đúng một vòng mỗi giây bằng transform: rotate. Dùng cho màn hình chờ trong lúc cả trang đang tải hoặc ngay sau khi bạn bấm một nút và cần một dấu hiệu tối thiểu.
.spinner {
width: 64px;
height: 64px;
border-radius: 50%;
border: 6px solid rgba($cream, .22);
border-top-color: $color;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
02Vòng đôi xoay ngược chiều
Hai vòng lồng nhau xoay ngược chiều nhau với tốc độ khác nhau — vòng ngoài mất 1.2 giây, vòng trong mất 0.9 giây — nên trông có chiều sâu hơn hẳn một vòng đơn. Phù hợp với loading trong modal hay thẻ dashboard đang chờ dữ liệu, nơi bạn muốn spinner nổi bật hơn một chút.
.spinner__ring--outer {
border-top-color: $color;
border-bottom-color: $color;
animation: spin 1.2s linear infinite;
}
.spinner__ring--inner {
inset: 13px;
border-left-color: $blue;
border-right-color: $blue;
animation: spin-rev .9s linear infinite;
}
@keyframes spin-rev {
to { transform: rotate(-360deg); }
}
033 chấm nảy
Ba chấm tròn nảy lên rồi rơi xuống theo đúng thứ tự, mỗi chấm trễ 0.16 giây so với chấm trước nhờ animation-delay nhân dần lên. Kiểu này quen thuộc trong khung chat khi đối phương đang gõ chữ hoặc lúc ô tìm kiếm đang chờ gợi ý.
.dots span {
width: 16px;
height: 16px;
border-radius: 50%;
animation: bounce .9s ease-in-out infinite;
}
.dots span:nth-child(2) { animation-delay: .16s; }
.dots span:nth-child(3) { animation-delay: .32s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); opacity: .5; }
40% { transform: translateY(-14px); opacity: 1; }
}
04Thanh vô định
Một thanh ngắn trượt qua lại trong khung rộng 100% bằng translateX kết hợp scaleX, không đổi giá trị width nên trình duyệt không phải tính lại layout ở mỗi khung hình. Đặt ở đầu trang hoặc ngay trước khi một file bắt đầu tải lên, lúc chưa biết % thật.
.bar__fill {
position: absolute;
inset: 0;
transform-origin: left center;
animation: sweep 1.5s ease-in-out infinite;
}
@keyframes sweep {
0% { transform: translateX(-100%) scaleX(.3); }
50% { transform: translateX(20%) scaleX(.6); }
100% { transform: translateX(100%) scaleX(.3); }
}
05Quét conic-gradient
Một conic-gradient duy nhất quay tròn, phần giữa bị khoét rỗng bằng mask nên chỉ còn lại một vòng cung mờ dần chứ không phải khối tròn đặc. Phù hợp cho màn hình loading cần giữ đúng màu thương hiệu, chẳng hạn lúc đang chờ thanh toán.
.spinner {
border-radius: 50%;
background: conic-gradient($color 0deg 270deg, rgba($cream, .18) 270deg 360deg);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
mask: radial-gradient(farthest-side, transparent calc(100% - 8px), #000 calc(100% - 8px));
animation: spin 1.1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
06Vòng tròn stroke-dashoffset
Một hình tròn SVG tự xoay trong khi chỉ riêng thuộc tính stroke-dashoffset co giãn, tạo cảm giác một nét vẽ đang chạy quanh vòng chứ không phải cả khối quay đều một màu. Dùng làm icon chờ khi bạn không cần con số phần trăm chính xác, kiểu màn hình splash của app.
.spinner { animation: spin 1.4s linear infinite; }
.spinner__arc {
stroke: $color;
stroke-linecap: round;
stroke-dasharray: 113;
stroke-dashoffset: 90;
animation: dash 1.4s ease-in-out infinite;
}
@keyframes dash {
0% { stroke-dashoffset: 100; }
50% { stroke-dashoffset: 20; }
100% { stroke-dashoffset: 100; }
}
07Vòng % với @property
Custom property --percent được đăng ký kiểu
@property --percent {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
.ring {
--percent: 0;
background: conic-gradient($color calc(var(--percent) * 1%), rgba($ink, .18) 0);
animation: fill 1.8s ease-in-out infinite;
}
@keyframes fill {
0% { --percent: 0; }
50% { --percent: 100; }
100% { --percent: 0; }
}
08Loading ngay trong nút
Bấm nút thì chữ bên trong mờ dần biến mất và một spinner nhỏ hiện lên đúng chỗ đó, vì cả hai lớp dùng chung một ô grid chồng lên nhau, còn chiều rộng nút bị khoá cứng bằng min-width nên nút không co lại theo chữ. Dùng cho nút lưu hay nút thanh toán, nơi người dùng cần biết chắc cú bấm đã được ghi nhận.
.btn__spin {
opacity: 0;
transform: scale(.6);
animation: spin .8s linear infinite;
}
.btn.is-loading .btn__label { opacity: 0; transform: translateY(6px); }
.btn.is-loading .btn__spin { opacity: 1; transform: scale(1); }
@keyframes spin { to { transform: rotate(360deg); } }
09Quả cầu pulse kiểu app AI
Hai lớp radial-gradient chồng lên nhau — một lớp mờ toả sáng ra ngoài, một lớp lõi rõ nét ở giữa — cùng phồng lên xẹp xuống theo chu kỳ 2 giây nhưng lệch biên độ nhau. Kiểu này hay gặp ở chatbot AI lúc đang soạn câu trả lời hoặc trợ lý giọng nói lúc đang lắng nghe bạn nói.
.orb::before {
background: radial-gradient(circle, rgba($color, .55), transparent 70%);
filter: blur(6px);
animation: glow 2s ease-in-out infinite;
}
.orb::after {
background: radial-gradient(circle at 35% 35%, $cream, $color 75%);
animation: core 2s ease-in-out infinite;
}
@keyframes core {
0%, 100% { transform: scale(.88); opacity: .88; }
50% { transform: scale(1.08); opacity: 1; }
}
Chỗ dễ hỏng ở đâu? — cái bẫy
Cả chín spinner này chạy trọn trong CSS bằng animation và keyframes, không cần một đoạn JavaScript rời nào polling trạng thái ở mỗi khung hình, đúng như tài liệu CSS Animations của MDN mô tả. Cái bẫy đầu tiên nằm ở bài 07: thuộc tính tuỳ chỉnh --percent chỉ nội suy mượt khi trình duyệt hiểu @property, tức là Chrome từ bản 85, Safari từ bản 16.4 và Firefox chỉ vừa thêm từ bản 128 — trên các bản Firefox cũ hơn, vòng tròn nhảy thẳng luôn về giá trị cuối chứ không đếm dần theo từng bước.
Cái bẫy thứ hai nằm ở bài 05: conic-gradient bị khoét rỗng bằng thuộc tính mask, và Safari chỉ chạy được mask không cần tiền tố -webkit- kể từ bản 15.4 (bảng tương thích mask trên MDN) — nên file demo giữ cả hai dòng, một dòng -webkit-mask đứng trước làm phương án dự phòng và một dòng mask không tiền tố đứng ngay sau. Nếu bạn muốn mở file ra xem ngay thay vì đọc tiếp phần còn lại, mã giải nén của gói zip là qda5pgmf.
Cái bẫy thứ ba nằm ngay ở lưới xem trước phía trên: khung đó chạy trong một iframe cỡ cố định, quá hẹp và quá ngắn để tái hiện đúng nhịp gốc của một vài spinner, nên mỗi ô tự chuyển qua một vòng lặp is-demo đều đặn thay cho hoạt ảnh gốc của nó. Bạn chỉ xem cách nó lặp ở đây thôi chứ không bấm được gì cả; muốn thấy đúng tốc độ và độ trễ như bản gốc, hãy mở riêng từng file index.html trong tab của bạn.
Khả năng tiếp cận
Chín demo đều tắt animation trong prefers-reduced-motion, nhưng không tắt sạch trơn — mỗi kiểu giữ lại một khung hình tĩnh đủ để vẫn đọc được là đang tải. Bài 01, 05 và 09 đứng yên ở đúng hình dạng ban đầu: vòng viền mờ, cung conic-gradient tĩnh, quả cầu giữ nguyên kích thước không phồng lên nữa. Bài 02 khoá cả hai vòng đứng yên tại chỗ, còn bài 03 và 04 chỉ giảm opacity (0.5 và 0.7) thay vì animation, để không biến mất hẳn khỏi màn hình. Bài 06 và 07 dừng lại ở một điểm giữa chừng có chủ đích — dashoffset 60 và --percent 62 — như một lát cắt của vòng đang chạy chứ không phải trạng thái đã hoàn tất. Bài 08 vẫn hiện spinner tĩnh khi nút ở is-loading, vì lúc đó bản thân trạng thái đang xử lý là thông tin cần giữ lại, chỉ riêng vòng xoay là tắt. Mỗi phần tử còn có role="status" hoặc role="progressbar" cùng aria-label để trình đọc màn hình biết đây là vùng đang tải mà không cần nhìn hoạt ảnh. Bảng hỗ trợ trình duyệt cho media query này nằm trong trang @media prefers-reduced-motion của MDN.
Xem các mẫu CSS khác trong chuyên mục CSS; trang giới thiệu nói thêm về việc ai đứng sau các gói zip này.
FAQ
Css loading spinner animation có cần JavaScript không?
Không cần. Toàn bộ chín kiểu ở đây chạy bằng animation và keyframes thuần CSS, chỉ riêng bài 08 có một đoạn JavaScript ngắn để bật hoặc tắt class is-loading khi bạn bấm nút thật — nhưng chính vòng xoay bên trong vẫn là CSS, đoạn script chỉ đổi class.
Vì sao dùng transform để xoay thay vì đổi border mỗi khung hình?
transform: rotate không bắt trình duyệt tính lại layout hay tô lại (repaint) toàn bộ phần tử ở mỗi khung hình, nó chỉ composite (tổng hợp) lại trên GPU, nên khối vẫn xoay mượt kể cả khi trang đang tải nặng. Cách còn lại — tính lại border bằng JavaScript mỗi frame — luôn giật hơn vì phải chạy lại style và layout liên tục.
Có ghép được các kiểu spinner này vào component React hoặc Tailwind không?
Có. Thư mục react/ trong gói zip bọc từng kiểu thành một component nhận màu và thời gian qua props, còn các class Tailwind bạn đặt bên ngoài không đụng vào những class .spinner, .dots hay .orb đang giữ animation, nên hai bên không giẫm lên nhau.