9 CSS Number Counter Animation — dán 1 dòng
CSS number counter animation là chữ số tự tăng từ 0 lên một giá trị đích bằng thuộc tính CSS, không cần JavaScript.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Đếm số nguyên bằng @property
- 02 Tự động chèn dấu phẩy hàng nghìn
- 03 Đếm số có định dạng thập phân
- 04 Đếm số tiền kèm ký hiệu tiền tệ
- 05 Vòng tròn phần trăm đồng bộ với số
- 06 3 thẻ thống kê đếm lần lượt
- 07 Chỉ bắt đầu khi cuộn vào tầm nhìn
- 08 Số cuộn kiểu đồng hồ odometer
- 09 Bộ đếm ngược thời gian
Chín cách đếm này xếp theo độ phức tạp của phép tính đứng sau con số, không theo mức phổ biến. 01 chỉ đếm một số nguyên. 02 tới 04 thêm định dạng — dấu phẩy hàng nghìn, số thập phân, ký hiệu tiền tệ. 05 và 06 gắn con số với một yếu tố khác — vòng tròn phần trăm, nhiều thẻ chạy lệch nhau. 07 tự quyết định khi nào bắt đầu đếm. 08 đếm bằng một cơ chế hoàn toàn khác — cuộn dải số thay vì đổi biến số. 09 đảo ngược hướng đếm về 0. Mỗi khối mã chỉ giữ phần thực sự bật animation, phần còn lại nằm trong file zip.
01Đếm số nguyên bằng @property
@property đăng ký --num làm một custom property kiểu số nguyên, rồi keyframes đẩy giá trị đó từ 0 lên 12,480 trong khi counter(num) vẽ đúng con số ấy thành chữ số thật trên màn hình. Cách này hợp với thẻ thống kê người đăng ký, lượt truy cập hay tổng số thành viên.
@property --num {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.num {
--num: 0;
counter-reset: num var(--num);
animation: count-up 2s ease-out infinite;
&::before { content: counter(num); }
}
@keyframes count-up {
0% { --num: 0; }
65% { --num: 12480; }
100% { --num: 12480; }
}
02Tự động chèn dấu phẩy hàng nghìn
Phần nghìn và phần dư được đếm bằng hai custom property @property tách riêng, còn @counter-style với pad: 3 '0' luôn giữ phần dư đủ ba chữ số để tránh ra kết quả sai như "12,3". Định dạng này hợp với tổng doanh thu, lượt xem hay số lượng tồn kho lớn.
@counter-style padded-3 {
system: numeric;
symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
pad: 3 '0';
}
.num {
counter-reset: k var(--k) n var(--n);
&::before { content: counter(k) "," counter(n, padded-3); }
}
03Đếm số có định dạng thập phân
Phần nguyên và phần thập phân chạy cùng lúc trên hai @property riêng, còn phần thập phân luôn được pad về đúng hai chữ số rồi nối sau dấu chấm. Kiểu đếm này hợp với điểm đánh giá, số sao trung bình hay chỉ số nhiệt độ.
@counter-style padded-2 {
system: numeric;
symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
pad: 2 '0';
}
.num {
counter-reset: whole var(--whole) frac var(--frac);
&::before { content: counter(whole) "." counter(frac, padded-2); }
}
04Đếm số tiền kèm ký hiệu tiền tệ
Ký hiệu tiền tệ được gắn cố định bằng ::before, còn ::after chỉ vẽ riêng counter(num) đang đếm, nên ký hiệu không bị đẩy đi khi số chữ số tăng từ một lên bốn. Cách gắn này hợp với giá nổi bật, số tiền giảm giá hay tổng kết thanh toán.
.num {
counter-reset: num var(--num);
animation: count-up .6s ease-out infinite;
&::before { content: "$"; margin-right: 2px; }
&::after { content: counter(num); }
}
05Vòng tròn phần trăm đồng bộ với số
Một @property số nguyên duy nhất vừa vẽ counter(p) thành chữ số vừa tính calc(var(--p) * 1%) cho góc tô của conic-gradient, nên vòng tròn và con số không bao giờ lệch nhau. Đồng bộ này hợp với tiến độ, tỷ lệ đạt mục tiêu hay dung lượng lưu trữ đã dùng.
.ring {
--p: 0;
background: conic-gradient($color calc(var(--p) * 1%), transparent 0);
animation: fill-up 2s ease-out infinite;
}
.ring__num {
counter-reset: p var(--p);
&::before { content: counter(p) "%"; }
}
063 thẻ thống kê đếm lần lượt
Ba thẻ không đếm cùng một nhịp — animation-delay lệch nhau 0,15 giây cho mỗi thẻ kế tiếp nên mắt có thứ tự để theo dõi thay vì phải nhìn ba con số nhảy cùng lúc. Cách này hợp với thẻ tóm tắt dashboard hay ba số liệu nổi bật trên landing page.
.card:nth-child(1) .num {
animation: count-a 2s ease-out infinite;
}
.card:nth-child(2) .num {
animation: count-b 2s ease-out infinite;
animation-delay: .15s;
}
.card:nth-child(3) .num {
animation: count-c 2s ease-out infinite;
animation-delay: .3s;
}
07Chỉ bắt đầu khi cuộn vào tầm nhìn
Số đếm đứng yên ở 0 cho tới khi IntersectionObserver thấy thẻ vượt ngưỡng hiển thị 60%, lúc đó mới gắn class is-counting để animation-play-state chuyển từ paused sang running. Kiểu trì hoãn này hợp với section thống kê trên landing page cuộn dài hay trang giới thiệu kiểu storytelling.
var card = document.querySelector('.card');
new IntersectionObserver(function (es) {
card.classList.toggle('is-counting', es[0].isIntersecting);
}, { threshold: .6 }).observe(card);
// .num mặc định animation-play-state: paused
// chỉ .is-counting .num mới chuyển sang running
08Số cuộn kiểu đồng hồ odometer
Mỗi hàng chữ số có một dải dọc 0 tới 9 riêng, cuộn bằng translateY đến đúng vị trí chữ số đích trong khi overflow: hidden trên ô cha chỉ để lộ đúng một hàng. Ba cột cuộn độc lập ghép thành "128", hợp với bộ đếm phong cách retro, bảng điểm game hay số thứ tự chờ ở kiosk.
.digit {
height: 44px;
overflow: hidden; // chỉ một hàng trong dải được lộ ra
}
.digit__strip {
transform: translateY(0); // giá trị thật do keyframes bên dưới quyết định
animation: roll-to-8 2s cubic-bezier(.2, 1.4, .4, 1) infinite;
span { height: 44px; }
}
@keyframes roll-to-8 {
0% { transform: translateY(0); }
60%, 100% { transform: translateY(-80%); } // dừng ở hàng thứ 8 (-80%)
}
09Bộ đếm ngược thời gian
@property --t đếm ngược từ 9 về 0 thay vì đếm lên một mục tiêu, và cùng giá trị --t đó vừa co ngắn thanh tiến độ bằng scaleX vừa chuyển màu chữ số sang màu cảnh báo ở giai đoạn cuối. Hướng đếm ngược này hợp với hạn chót số lượng có hạn, sự kiện sắp kết thúc hay cảnh báo sắp hết hàng.
@keyframes count-down {
0% { --t: 9; color: $color; }
60% { --t: 3; color: $color; }
85% { --t: 0; color: $error; }
100% { --t: 0; color: $error; }
}
.bar::after {
transform-origin: left;
transform: scaleX(calc(var(--t) / 9)); // cùng --t nên thanh cũng rút ngắn theo
}
Bẫy đếm số nằm ở đâu?
Cả chín cách đếm đều dựa vào @property, và at-rule này chỉ chạy được từ Chrome·Edge 85, Safari 16.4 và Firefox 128 trở lên, theo bảng hỗ trợ @property trên caniuse. Trên trình duyệt cũ hơn, animation bị bỏ qua hoàn toàn và con số cuối cùng hiện thẳng ra ngay từ đầu — bố cục không vỡ, chỉ mất hẳn cảm giác đang đếm lên.
Bẫy thứ hai nằm ở chính counter(): con số nó vẽ ra không phải văn bản thật, nên không thể dùng chuột bôi đen để chọn, và trình đọc màn hình cũng bỏ qua, đúng như tài liệu nội dung sinh ra của MDN ghi rõ. Vì vậy cả chín demo trong zip đều giấu thêm một đoạn văn bản số thật ngay bên cạnh, chỉ ẩn đi bằng CSS chứ không xoá khỏi DOM — mở gói bằng mật khẩu j9xejany để thấy đúng cách giấu đó trong từng file.
Khả năng tiếp cận
Cả chín cách đếm đều tôn trọng prefers-reduced-motion: animation tắt hẳn và mỗi ô hiện thẳng giá trị cuối cùng — mục 01 dừng ở 12.480, mục 03 ở 4,98, mục 05 ở 76% — không qua bước đếm nào cả, kể cả mục 07 vốn chờ cuộn tới cũng khoá sẵn ở 1.280 thay vì đứng ở 0. Vì counter() không phải văn bản thật, chín demo đều giữ thêm một span chứa số thật, chỉ ẩn khỏi mắt bằng clip-path: inset(50%) nhưng vẫn nằm trong DOM cho trình đọc màn hình. Mục 09 đổi màu chữ số sang màu cảnh báo để báo hiệu khẩn cấp, nhưng không phải ai cũng phân biệt được màu sắc, nên con số tự nó vẫn phải mang đủ nghĩa. Trang prefers-reduced-motion của MDN ghi mức hỗ trợ trình duyệt cho media query này.
Các hiệu ứng chuyển động khác cùng kiểu chín hiệu ứng trong một file nằm trong chuyên mục CSS của site, còn trang giới thiệu nói rõ vì sao mỗi bài ở đây đóng gói thành một file zip có khoá.
FAQ
CSS number counter animation có cần JavaScript không?
Gần như không. Tám trong chín cách đếm chạy trọn bằng @property và CSS animation, không có phép tính nào phải nhờ đến JavaScript. Riêng mục 07 mượn vài dòng IntersectionObserver để quyết định thời điểm bắt đầu, nhưng việc tăng số vẫn do CSS đảm nhiệm — nên xem qua bảng hỗ trợ @property trước khi dùng, vì trình duyệt cũ sẽ bỏ qua animation này.
Số do counter() vẽ ra có copy được không?
Không copy được bằng chuột và trình đọc màn hình cũng bỏ qua, vì đó là nội dung sinh ra chứ không phải node văn bản thật trong DOM. Nếu cần cho phép người dùng copy được số đó (như bảng giá), hãy hiện hẳn span văn bản thật đã giấu sẵn trong mỗi demo thay vì để nguyên trạng thái ẩn.
Odometer ở mục 08 có đếm được số hàng vạn không?
Có — chỉ cần thêm một cột digit cho mỗi chữ số mới, còn cách tính translateY của từng cột giữ nguyên như cũ. Thư mục react/ trong zip đã viết component odometer nhận một mảng chữ số dài ngắn tùy ý, nên thêm cột không cần viết lại logic cuộn.